# Technical Documentation: mavlink/qgroundcontrol

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/mavlink/qgroundcontrol)

### Overview (`README.md`)

<p align="center">
  <img src="https://raw.githubusercontent.com/Dronecode/UX-Design/35d8148a8a0559cd4bcf50bfa2c94614983cce91/QGC/Branding/Deliverables/QGC_RGB_Logo_Horizontal_Positive_PREFERRED/QGC_RGB_Logo_Horizontal_Positive_PREFERRED.svg" alt="QGroundControl Logo" width="500">
</p>

<p align="center">
  <a href="https://github.com/mavlink/QGroundControl/releases"><img src="https://img.shields.io/github/v/release/mavlink/QGroundControl" alt="Latest Release"></a>
  <a href="https://github.com/mavlink/qgroundcontrol/blob/master/.github/COPYING.md"><img src="https://img.shields.io/github/license/mavlink/QGroundControl" alt="License"></a>
  <a href="https://github.com/mavlink/QGroundControl/actions/workflows/linux.yml"><img src="https://github.com/mavlink/QGroundControl/actions/workflows/linux.yml/badge.svg" alt="Linux Build"></a>
  <a href="https://securityscorecards.dev/viewer/?uri=github.com/mavlink/qgroundcontrol"><img src="https://img.shields.io/ossf-scorecard/github.com/mavlink/qgroundcontrol?label=openssf%20scorecard" alt="OpenSSF Scorecard"></a>
  <a href="https://crowdin.com/project/qgroundcontrol"><img src="https://badges.crowdin.net/qgroundcontrol/localized.svg" alt="Crowdin"></a>
  <a href="https://discord.com/channels/1022170275984457759/1022185820683255908"><img src="https://img.shields.io/discord/1022170275984457759?logo=discord&logoColor=white&label=Discord" alt="Dronecode Discord"></a>
  <a href="https://doi.org/10.5281/zenodo.595404"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.595404.svg" alt="DOI"></a>
</p>

**QGroundControl** (QGC) is a Ground Control Station (GCS) for UAVs, providing full flight control
and mission planning for any *MAVLink-enabled* drone, including *PX4* and *ArduPilot* platforms.

## Features

- **Mission planning** — plan, edit, and fly autonomous waypoint, survey, and structure-scan missions.
- **Live Fly View** — real-time flight display with map, instruments, and full vehicle telemetry.
- **Vehicle setup** — guided wizards for sensor calibration, radio, flight modes, and power.
- **Parameter tuning** — inspect and edit every vehicle parameter through the Fact System.
- **Video streaming** — GStreamer-based UDP RTP / RTSP video with recording in the Flight Display.
- **Multi-vehicle** — connect to and monitor multiple vehicles simultaneously.
- **MAVLink tooling** — built-in MAVLink Inspector, console, and log download/analysis.
- **Cross-platform** — Windows, macOS, Linux, Android, and iOS from a single codebase.

## Download

Grab the latest stable build for your platform, or see all assets on the
[releases page](https://github.com/mavlink/QGroundControl/releases/latest):

<p align="center">
  <a href="https://github.com/mavlink/QGroundControl/releases/latest/download/QGroundControl-installer-AMD64.exe"><img src="https://img.shields.io/badge/Windows-0078D6?logo=windows&logoColor=white" alt="Windows"></a>
  <a href="https://github.com/mavlink/QGroundControl/releases/latest/download/QGroundControl.dmg"><img src="https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=white" alt="macOS"></a>
  <a href="https://github.com/mavlink/QGroundControl/releases/latest/download/QGroundControl-x86_64.AppImage"><img src="https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black" alt="Linux (AppImage)"></a>
  <a href="https://github.com/mavlink/QGroundControl/releases/latest/download/QGroundControl.apk"><img src="https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white" alt="Android"></a>
</p>

## Links

- [Official Website](https://qgroundcontrol.com)
- [User Manual](https://docs.qgroundcontrol.com/master/en/qgc-user-guide/)
- [Developer Guide](https://docs.qgroundcontrol.com/master/en/qgc-dev-guide/) / [Build Instructions](https://docs.qgroundcontrol.com/master/en/qgc-dev-guide/getting_started/)
- [Discussion & Support](https://docs.qgroundcontrol.com/master/en/qgc-user-guide/support/support.html)
- [Dronecode Discord](https://discord.com/channels/1022170275984457759/1022185820683255908)
- [Security Policy](.github/SECURITY.md)
- [Code of Conduct](.github/CODE_OF_CONDUCT.md)
- [License](https://github.com/mavlink/qgroundcontrol/blob/master/.github/COPYING.md)

## Contributing

QGC is open source and welcomes contributions. See [AGENTS.md](AGENTS.md) for build/test/lint
commands and coding conventions, and [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) for
architecture patterns and the contribution workflow.

QGC's interface is translated by the community — help translate it into your language on
[Crowdin](https://crowdin.com/project/qgroundcontrol).

## Star History

[](https://star-history.dera.page/#mavlink/qgroundcontrol&type=date&legend=top-left)

---

### Overview (`cmake/README.md`)

# CMake Development Guide

This directory contains QGroundControl's reusable build modules, platform configuration, package
assembly, dependency finders, and pure-CMake regression tests. The root `CMakeLists.txt` remains the
entry point for normal builds.

## Layout

| Path | Purpose |
| --- | --- |
| `*.cmake` | Build options (`CustomOptions.cmake`), shared helpers (`Helpers.cmake`), and test registration (`QGCTest.cmake`) |
| `*.in` | Templates configured into the build directory: `CTestCustom.cmake.in` (Valgrind memcheck) and `ccache-launcher.cmd.in` (Windows ccache launcher) |
| `GStreamer/` | GStreamer discovery, download, validation, installation, and pure-CMake tests |
| `install/` | Runtime deployment and package assembly |
| `modules/` | Reusable project modules and dependency helpers |
| `platform/` | Android, Apple, Linux, and Windows configuration |
| `presets/` | Platform configure, build, test, package, and workflow presets |
| `tests/` | Configure-time contracts for shared CMake helpers |

## Conventions

- Add `include_guard(GLOBAL)` to reusable modules that define functions, macros, options, or global
  state.
- Prefix project-owned functions, macros, targets, and cache variables with `qgc` or `QGC`.
- Validate required and unknown arguments in public helper functions. Fail during configure rather
  than allowing a misspelled option to be ignored.
- Prefer target-scoped properties and commands over directory-global compile or link flags.
- Account for single- and multi-config generators. Custom test targets should pass their active
  configuration to CTest.
- Keep host and target paths distinct in cross-builds, especially for Android and iOS.
- In GStreamer modules with repeated basenames, include the intended file by absolute path.
- When changing an existing file, format only the edited region. Whole-file formatting is reserved
  for new files or an explicit formatting task.
- The lint hooks run cmakelang 0.6.13, which predates `return(PROPAGATE)` and `file(ARCHIVE_EXTRACT)`. Silence its
  findings on those with a `# cmake-lint: disable=` line in the function; no other comment is needed.

`cmake/modules/CPM.cmake` is vendored. Do not modify or reformat it as part of project CMake
maintenance; the pre-commit CMake hooks intentionally exclude it.

The standard application's autogen target depends explicitly on `qgc-analysis-headers` instead
of waiting for all linked libraries. Add new build-time header generators to that prerequisite
target when moc can consume them. Custom builds retain forwarded dependencies because their
additional generators are not known to the standard build.

`QGC_USE_MOCCACHE` (default `ON`) routes AUTOMOC through a generated `<build>/moccache-launcher`, which runs
`tools/moccache_launcher.py` to import [`tools/moccache/`](../tools/README.md#moccache) with cached bytecode.
It needs Python 3.10 or newer (`QGC_MOCCACHE_PYTHON`) and, on Windows, CMake 3.29; otherwise configure falls back
to plain moc. Configure rewrites the launcher only when its content changes, because AUTOMOC re-runs moc for every
target whose launcher is newer than its output.

`QGC_USE_LLD_LINK` (default `OFF`) links MSVC builds with `lld-link` through `CMAKE_LINKER_TYPE` (CMake 3.29+). It
applies only without IPO (single-config Debug or RelWithDebInfo): lld-link cannot link the `/GL` objects of Release
and multi-config builds, which keep `link.exe`. CMake must find `lld-link` when it first detects the compiler (next to
`cl.exe` or on `PATH`: install LLVM, or add `VC\Tools\Llvm\x64\bin` from Visual Studio's "C++ Clang tools"), or take
it from `-DCMAKE_LINKER_LLD=<path>` on a fresh configure; otherwise configure keeps `link.exe`.

Run the CMake lint hooks on the files you change:

```bash
prek run cmake-format --files path/to/file.cmake
prek run cmake-lint --files path/to/file.cmake
```

The manual-stage `gersemi` hook trials [gersemi](https://github.com/BlankSpruce/gersemi) as a cmakelang
replacement: `prek run gersemi --hook-stage manual --files path/to/file.cmake` prints the diff it would apply
(configured by [`tools/configs/gersemirc.yaml`](../tools/configs/gersemirc.yaml)) without rewriting the file.
cmake-format and cmake-lint remain the enforced checks.

## Tests

After configuring a build with tests enabled, run the shared-module (`tests/`) and GStreamer (`GStreamer/tests/`,
also labelled `GStreamer`) script and fixture tests with:

```bash
ctest --test-dir build --output-on-failure -L CMake
```

Use `just configure`, `just build`, and the relevant CTest labels for the final validation described
in the root [AGENTS.md](../AGENTS.md).

`QGCTest.cmake` provides `add_qgc_test()` (see [test/README.md](../test/README.md#register-in-cmakeliststxt)), the
`check*` convenience targets, and these cache options:

| Option | Default | Purpose |
| --- | --- | --- |
| `QGC_TEST_TIMEOUT_UNIT`, `_INTEGRATION`, `_SLOW`, `_DEFAULT` | 60, 120, 180, 90 | Per-label CTest timeouts in seconds; sanitizer builds raise them |
| `QGC_TEST_PARALLEL_LEVEL` | Logical cores | Parallelism of the `check*` targets |
| `QGC_TEST_FLAKY_REPEAT` | 3 | `check-flaky` repeat count |
| `QGC_TEST_ONSCREEN` | `OFF` | Run tests on the native display instead of offscreen |
| `QGC_TEST_DETECT_LEAKS` | `OFF` | Let ASan run LSan leak detection, which needs `ptrace` permission |

Sanitizer builds set each enabled sanitizer's `*SAN_OPTIONS` (with `halt_on_error=1`) on every CTest test, so
findings fail the test; the values replace any set in the calling environment.

## GStreamer

`GStreamer/` discovers or downloads the GStreamer SDK and creates the `GStreamer::` targets. The GStreamer
[README](../src/VideoManager/VideoReceiver/GStreamer/README.md#build-configuration) describes its modules and configure
options.

Each HwBuffers zero-copy GPU path can be compiled out with `QGC_GST_ENABLE_<PATH>=OFF` (DMABUF, CUDA, GLMEMORY,
IOSURFACE, D3D11, D3D12, AHARDWAREBUFFER, VULKAN; all ON by default). Configure prints which compiled paths the sink
can't reach at runtime.

## Presets

The root [`CMakePresets.json`](../CMakePresets.json) aggregates the platform definitions in
`presets/`. CI selects one of those configure presets and limits workflow overrides to dynamic
values such as signing credentials, sanitizer selection, and target-specific paths. Keep build
type, generator, testing, coverage, toolchain, and cache defaults in the presets so local and CI
configuration cannot drift.

The root file owns `cmakeMinimumRequired`; included fragments declare only their preset schema
`version`. Every file uses version 8, the newest that the minimum CMake (3.28) reads, and names the
official CMake presets JSON schema in `$schema` (version 8 or later) for editor validation and
completion. Preset build directories stay under `${sourceDir}/build` so separate source
checkouts cannot share or overwrite one another's build trees.

List every configure, build, test, package, and workflow preset with:

```bash
cmake --list-presets=all
```

Each visible configure preset has a same-name build preset and workflow preset. Workflows run tests
only for configurations that set `QGC_BUILD_TESTING=ON`; mobile and ARM64 cross-build debug presets
intentionally keep tests disabled because their target binaries cannot be assumed runnable on the
host. For example:

```bash
cmake --workflow --preset Linux-debug
cmake --workflow --preset Linux-deb
```

`Linux-clang-debug` is `Linux-debug` built with Clang, which loads precompiled headers far faster than
GCC: a cold build on 24 cores takes about 75 s instead of 205 s. Clang emits a class's debug info only
where its key function is compiled, so types from libraries without debug symbols (such as Qt without
its debug files) can show as incomplete in the debugger; add `-fstandalone-debug` to
`CMAKE_CXX_FLAGS_DEBUG` if that gets in the way.

The hidden `lean-debug` preset uses line-table debug info (`-g1`), which is all gcov and sanitizer stack traces
need, and unity builds of QGC targets (`QGC_UNITY_BUILD=ON`). CI builds with these presets, which inherit it:

- `Linux-coverage` — Debug with tests and `QGC_ENABLE_COVERAGE=ON`, in `build/Linux-coverage`.
- `Linux-sanitizers` — Debug with tests, ASan + UBSan and `QGC_TEST_DETECT_LEAKS=ON`, in `build/Linux-sanitizers`;
  its test preset also excludes `NoSanitizer`. `cmake --workflow --preset Linux-sanitizers` reproduces CI's
  sanitizer job.
- `iOS-simulator` — x86_64 simulator Debug build without tests.

The `just configure` and `just build` recipes use the matching `default*` configure and build
presets for `BUILD_TYPE`. `just test` always uses the `default` test preset and passes `BUILD_TYPE`
through `--build-config`. `tools/configure.py` selects a configure preset by build type and accepts
an explicit `--preset`, configuring into the preset's `binaryDir` unless `-B` is given. Coverage uses
`Linux-coverage`. Docker uses the platform presets with mounted build-directory overrides;
unsupported build types and its dynamic ARM64 sysroot keep explicit `qt-cmake` configuration.
Multipass and Vagrant both run `deploy/multipass/build_in_vm.py`, which passes the matching `Linux*` preset to
`qt-cmake` with a build-directory override (MinSizeRel has no preset).

| Preset family | Required environment | Notes |
| --- | --- | --- |
| `default*` | A discoverable Qt installation | Generic Ninja build in the source tree |
| `Linux*`, `macOS*`, `Windows*` | `QT_ROOT_DIR` | Qt target installation containing `qt.toolchain.cmake` |
| `Linux-arm64*`, `Windows-arm64*` | `QT_ROOT_DIR`, `QT_HOST_PATH` | Target and host Qt installations for ARM64 or cross-builds |
| `Android*` | `QT_TARGET_ROOT_DIR`, `QT_HOST_PATH`, `ANDROID_NDK`, `ANDROID_MIN_SDK` | Qt for Android plus its matching host Qt and Android toolchain |
| `iOS*` | `QT_ROOT_DIR`, `QT_HOST_PATH` | Qt for iOS plus its matching host Qt |

Package presets exist only for CPack-backed artifacts: Linux `.deb`/`.rpm` packages and Windows
NSIS installers. The normal Linux AppImage and macOS DMG are install-time artifacts, so configure
and build their release workflow and then run `cmake --install <build-dir> --config Release`.
Android APK signing/deployment and iOS IPA assembly remain platform workflow steps rather than CPack
operations.

Use `CMakeUserPresets.json` for machine-local paths or derived presets; it remains ignored by Git
and `tools/clean.py` preserves it.

---

### Overview (`custom-example/README.md`)

# QGroundControl Ground Control Station

## Custom Build Example

To build this sample custom version:

1. Start from a clean build directory (`just clean`).
2. Rename `custom-example` to `custom` (the default `QGC_CUSTOM_DIR`), or keep the name and pass
   `-DQGC_CUSTOM_DIR=custom-example` when configuring.
3. Configure and build QGC from the repository root as usual.



See the [QGC Dev Guide](https://docs.qgroundcontrol.com/master/en/qgc-dev-guide/custom_build/custom_build.html)
for what a custom build is and how to create your own.

This example demonstrates:

- **Off-the-shelf commercial vehicle** — most vehicle setup is hidden (pre-configured by the vendor),
  giving a simpler UI; the full experience stays available in **Advanced Mode**.
- **Custom branding** — images and color palette matching a corporate identity.
- **Custom interface** — e.g. a custom instrument widget replacing the standard QGC UI (see screenshot).
- **Overridden application settings** — hides options users shouldn't change and adjusts defaults.
- **Fully commented source** explaining what it does and why.

---

### Overview (`test/README.md`)

# QGroundControl Unit Testing

> Agent guidance (naming, review process, architecture patterns) lives in [AGENTS.md](../AGENTS.md);
> this doc covers the test framework itself.

## Table of Contents

- [Quick Start](#quick-start)
- [Test Framework Overview](#test-framework-overview)
  - [Base Test Classes](#base-test-classes)
- [Writing Tests](#writing-tests)
  - [Test Quality](#test-quality)
  - [Basic Test Class](#basic-test-class)
  - [Data-Driven Tests](#data-driven-tests-default-for-permutations)
  - [Register in CMakeLists.txt](#register-in-cmakeliststxt)
  - [CTest Labels & Timeouts](#ctest-labels--timeouts)
  - [Wait/Timeout Helpers](#waittimeout-helpers)
- [Running Tests](#running-tests)
  - [Via CTest](#via-ctest)
  - [Via `just`](#via-just)
  - [Via the QGroundControl Binary](#via-the-qgroundcontrol-binary-unittest-build)
  - [Convenience Targets](#convenience-targets)
  - [QML Test Split](#qml-test-split)
- [Portable Utility Tests](#portable-utility-tests)
- [GPS Protocol Golden Transcripts](#gps-protocol-golden-transcripts)
- [MultiSignalSpy](#multisignalspy)
- [Manual Test Plans](#manual-test-plans)
- [Code Coverage](#code-coverage)
- [Sanitizers](#sanitizers)
- [Debugging Test Failures](#debugging-test-failures)

## Quick Start

Debug builds enable tests (`QGC_BUILD_TESTING`) by default.

```bash
just configure && just build                               # Debug build in build/
just test                                                  # Unit|Integration, excluding Flaky|Network
ctest --test-dir build -R ParameterManagerTest             # One test (all of its shards)
build/Debug/QGroundControl --unittest:ParameterManagerTest # One suite, directly (Linux path)
```

## Test Framework Overview

Tests are Qt `QObject`-based classes that subclass one of the framework's base classes
(below) and self-register via `UT_REGISTER_TEST`. There is no central list of tests to
update — `main.cc`, `QGCApplication`, `QGCCommandLineParser`, and `UnitTestList` are all
generic and enumerate tests at runtime via `UnitTest::registeredTests()`.

### Base Test Classes

Pick the narrowest base class that gives you the fixtures you need — each one builds on
the last, so you get the parent's setup/teardown for free.

| Class                         | Extends       | Use For                                              |
| ----------------------------- | ------------- | ---------------------------------------------------- |
| `UnitTest`                    | —             | Basic tests, no special setup                        |
| `JsonTest`                    | `UnitTest`    | Tests parsing/validating JSON documents              |
| `StateMachineTest`            | `UnitTest`    | Tests driving a `QStateMachine`-based controller     |
| `CommsTest`                   | `UnitTest`    | Tests requiring LinkManager/MockLink                 |
| `TerrainTest`                 | `UnitTest`    | Tests requiring TerrainQuery                         |
| `VehicleTest`                 | `UnitTest`    | Tests requiring a connected Vehicle                  |
| `VehicleTestManualConnect`    | `VehicleTest` | Vehicle tests that connect explicitly mid-test       |
| `VehicleTestNoInitialConnect` | `VehicleTest` | Vehicle tests that skip the default auto-connect     |
| `VehicleTestAPM`              | `VehicleTest` | Vehicle tests against an ArduPilot-flavored MockLink |
| `ParameterTest`               | `VehicleTest` | Tests involving ParameterManager                     |
| `MissionTest`                 | `VehicleTest` | Tests involving MissionController on a live Vehicle  |
| `OfflineMissionTest`          | `UnitTest`    | Mission tests that don't need a connected Vehicle    |

## Writing Tests

### Test Quality

Cover as much behavior as possible with as little code as possible — a few strong tests beat many
redundant ones. More tests ≠ better coverage.

- **One behavior per test, one reason to fail.** Each test asserts a single contract or edge case. If
  two tests fail for the same cause, merge them.
- **Consolidate permutations.** Fold near-identical cases that vary only in input into one
  [data-driven test](#data-driven-tests-default-for-permutations), not copy-pasted methods.
- **Test behavior, not implementation.** Assert observable outputs and effects, not private state or
  call order — the latter break on every refactor without catching real regressions.
- **Prioritize edge cases and failure paths** (boundaries, empty/null, error handling) over repeated
  happy-path variations. Don't test Qt or the standard library.
- **A test that can't fail is noise.** No tautologies, no asserting the mock. If you can't name the
  regression it catches, don't add it — and before extending a suite, read it and consolidate into the
  nearest existing test rather than appending a redundant one.

### Basic Test Class

```cpp
#include "UnitTest.h"

class MyTest : public UnitTest {
    Q_OBJECT
private slots:
    void initTestCase();    // One-time setup (optional)
    void cleanupTestCase(); // One-time teardown (optional)
    void init();            // Per-test setup (optional)
    void cleanup();         // Per-test teardown (optional)

    void _myTestCase();     // Test method (prefix with _)
};

UT_REGISTER_TEST(MyTest, TestLabel::Unit)
```

Available `TestLabel::*` enum values: `Unit`, `Integration`, `Vehicle`, `MissionManager`,
`Comms`, `Utilities`, `Slow`, `Network`, `Serial`, `Joystick`, `AnalyzeView`, `Terrain`.
CTest `LABELS` set in `CMakeLists.txt` are a superset — some tests carry extra
CTest-only names (e.g. `MAVLink`, `Camera`) that have no corresponding `TestLabel` enum
value; the two lists are independent.

### Data-driven tests (default for permutations)

For tests that repeat the same assertions across a set of literal inputs, use Qt's
table-driven pattern (`_data()` + `QTest::addColumn`/`QFETCH`) rather than copy-pasting
near-identical method bodies. Each row gets its own failure tag, so a single bad
permutation is identified without re-running the whole method. See
`Utilities/Compression/QGCCompressionTest.cc` (`_testFormatDetection`,
`_testDetectFormatFromMagicBytes`) for the canonical shape.

### Register in CMakeLists.txt

Add test sources to the `QGCUnitTests` object library. The application executable links it and runs
the suite; tests compile with the application's include paths and definitions, and their precompiled
header adds `<QtTest/QTest>` so application sources do not compile QtTest.

```cmake
target_sources(
    QGCUnitTests
    PRIVATE
        MyTest.cc
        MyTest.h
)

# Basic unit test
add_qgc_test(MyTest LABELS Unit)

# Integration test
add_qgc_test(MyIntegrationTest LABELS Integration Vehicle)

# Test with custom timeout
add_qgc_test(MySlowTest LABELS Slow TIMEOUT 300)

# Timing-sensitive test: runs alone (RUN_SERIAL) in sanitizer/coverage builds, otherwise reserves 2 CTest processors
add_qgc_test(MyExclusiveTest LABELS Integration SERIAL)

# Tests sharing a fixed resource never run concurrently (SERIAL and RESOURCE_LOCK are mutually exclusive)
add_qgc_test(MyTest LABELS Unit RESOURCE_LOCK Joystick)

# Long test class split across CTest entries MyUITest.1 .. MyUITest.3 that run in parallel
add_qgc_test(MyUITest LABELS Integration SHARDS 3)
```

Every test gets its own `TMPDIR` (and `TMP`/`TEMP` on Windows) under `build/test-tmp/`, so
`RESOURCE_LOCK TempFiles` is ignored. `ENV_MODIFICATION` and `SKIP_REGEX` set CTest's
`ENVIRONMENT_MODIFICATION` and `SKIP_REGULAR_EXPRESSION`.

`SHARDS <count>` gives each entry every count-th test function in declaration order
(`--unittest-shard:<index>/<count>`), so new functions are picked up automatically. Data rows of
one function stay in the same shard; a shard that selects no functions fails, so lower `SHARDS`
when a class loses functions.

### CTest Labels & Timeouts

Only three labels set the CTest timeout directly; everything else is a filterable
category that combines with one of them (or falls back to the 90s default).

**Timeout tiers** (`cmake/QGCTest.cmake`, non-sanitizer build):

| Label                 | Timeout | Applies When                                              |
| --------------------- | ------- | --------------------------------------------------------- |
| `Unit`                | 60s     | `LABELS` includes `Unit` and not `Slow`                   |
| `Integration`         | 120s    | `LABELS` includes `Integration` and not `Slow`            |
| `Slow`                | 180s    | `LABELS` includes `Slow` (overrides `Unit`/`Integration`) |
| *(none of the above)* | 90s     | Default fallback (`QGC_TEST_TIMEOUT_DEFAULT`)             |

Sanitizer builds (`QGC_ENABLE_ASAN`/`UBSAN`/`TSAN`/`MSAN`) raise these to at least 180/360/540/270s
(`test/CMakeLists.txt`) to absorb instrumentation overhead. `TIMEOUT <seconds>` on `add_qgc_test()`
always wins; `${QGC_TEST_TIMEOUT_EXTENDED}` (180s, 360s with sanitizers) is the usual value for long tests.

**Category labels** (filtering only, no timeout of their own):

| Label            | Meaning                                                                                   |
| ---------------- | ----------------------------------------------------------------------------------------- |
| `Vehicle`        | Requires a MockLink-connected Vehicle                                                     |
| `MissionManager` | Mission planning tests                                                                    |
| `Comms`          | Communication/link tests                                                                  |
| `Utilities`      | Utility class tests                                                                       |
| `Network`        | Needs network; excluded from PR suites, covered by manual Linux coverage runs             |
| `Flaky`          | Intermittent failures; excluded from PR suites, covered by manual Linux coverage runs     |
| `Serial`         | C++ `TestLabel::Serial` tag, no CTest effect; `add_qgc_test(... SERIAL)` doesn't add it   |
| `Joystick`       | Joystick/controller tests                                                                 |
| `AnalyzeView`    | Log analysis and geo-tagging tests                                                        |
| `Terrain`        | Terrain query and tile tests                                                              |
| `GPS`            | GPS receiver, corrections and NTRIP tests; every test under `test/GPS` carries it         |
| `Fuzz`           | Fuzzer smoke runs: fixed inputs through a fuzz harness, without libFuzzer                 |
| `QML`            | QML test runners and QML lint checks                                                      |
| `NoSanitizer`    | Incompatible with ASan/UBSan; excluded by sanitizer CI and the `Linux-sanitizers` preset  |
| `StockUI`        | Asserts stock UI that custom builds replace; excluded from Custom Build integration tests |
| `Portable`       | [Portable utility tests](#portable-utility-tests)                                         |
| `CMake`          | [CMake script tests](../cmake/README.md#tests); the GStreamer ones also carry `GStreamer` |

Other CTest-only labels (`MAVLink`, `Camera`, `Viewer3D`) only name an area.

### Wait/Timeout Helpers

#### Prefer `TestTimeout::*` over literal millisecond values

All `.wait()`, `QTRY_*_WITH_TIMEOUT`, and `QVERIFY_*_WAIT` calls should use the
CI-adaptive helpers rather than hard-coded numbers. The helpers auto-scale (roughly 2×)
on CI so the same test works on a loaded build agent without slowing local runs:

```cpp
#include "UnitTest.h"  // provides TestTimeout namespace

TestTimeout::shortMs()   // 1s local / 2s CI   — quick signals, in-memory ops
TestTimeout::mediumMs()  // 5s local / 10s CI  — normal async work, file I/O
TestTimeout::longMs()    // 30s local / 60s CI — vehicle connect, FTP, metadata
```

Chrono-typed variants are also available: `TestTimeout::shortDuration()`,
`mediumDuration()`, `longDuration()`.

#### Wait macros

Prefer the QGC wrapper macros over raw `QSignalSpy::wait()`/`QTest::qWaitFor()` — they
emit readable failure messages that include the signal/condition name and the elapsed
time:

```cpp
QVERIFY_SIGNAL_WAIT(spy, TestTimeout::shortMs());          // a new emission (see gotcha below)
QVERIFY_SIGNAL_COUNT_WAIT(spy, 3, TestTimeout::shortMs()); // spy.count() >= 3
QVERIFY_NO_SIGNAL_WAIT(spy, 250);                          // see gotcha below
QVERIFY_TRUE_WAIT(condition, TestTimeout::shortMs());      // QTRY_VERIFY wrapper
QCOMPARE_TRUE_WAIT(actual, expected, TestTimeout::shortMs());
```

**Gotcha — `QVERIFY_SIGNAL_WAIT` waits for a new emission.** It calls `QSignalSpy::wait()`,
so a signal already in the spy doesn't count. Use `QVERIFY_SIGNAL_COUNT_WAIT(spy, n, …)`
when the signal may already have fired, e.g. when another thread emits it.

**Gotcha — `QVERIFY_NO_SIGNAL_WAIT` always burns the full timeout.** It has to, because
proving absence requires waiting the entire window. Keep its timeout as tight as
possible (a few hundred ms), and put it *after* any `QVERIFY_TRUE_WAIT` that already
proves state has settled. Prefer a positive barrier: send a trailing valid input and expect
exactly one signal, or wait for the terminal signal and then check the spy is empty. When the
code under test handles a reply synchronously, check the spy right after the call. In UI tests,
use `findVisibleItem(root, name, 0)` for negative checks after a positive barrier and inside
`QTRY_*`.

**Gotcha — waits in teardown after a failure.** Once the current test function has failed,
`QTestEventLoop` returns immediately, so `QSignalSpy::wait()`, `QVERIFY_SIGNAL_WAIT` and
`QVERIFY_SIGNAL_COUNT_WAIT` don't wait. Teardown helpers poll with
`QTest::qWaitFor(condition, timeout)` and settle before asserting.

**Gotcha — MockLink telemetry starts after the MAVLink v2 upgrade.** MockLink sends MAVLink v1
until QGC's first v2 message, and QGC drops v1 messages other than HEARTBEAT (and RADIO_STATUS).
MockLink therefore sends its first 1 Hz batch (SYS_STATUS, battery, HOME_POSITION, …) right after
that upgrade, then once a second. Tests that need two batches still wait about a second.

#### Condition polling

```cpp
QVERIFY(UnitTest::waitForCondition([&]() { return thing.ready(); },
                                   TestTimeout::shortMs(),
                                   QStringLiteral("thing.ready()")));
```

#### Avoid

- `QTest::qWait(N)` — unconditional sleep, doesn't early-exit. The `check-no-fixed-qwait`
  pre-commit hook rejects any non-zero literal passed to `qWait()` in `test/`.
- Polling `while (!done) { QTest::qWait(20); }` — use `QTRY_VERIFY_WITH_TIMEOUT` or
  `waitForCondition` instead.
- Literal milliseconds in timeouts. If you *must* use a literal (e.g. tight
  `QVERIFY_NO_SIGNAL_WAIT`), add a comment explaining why `TestTimeout::*` isn't
  appropriate.

## Running Tests

### Via CTest

```bash
ctest --test-dir build --parallel $(nproc)          # All tests in parallel
ctest --test-dir build --output-on-failure -L Unit  # Unit tests, print output on failure
ctest --test-dir build -L Integration               # Integration tests
ctest --test-dir build -LE "Flaky|Network"          # Exclude flaky and network tests
ctest --test-dir build -R "Camera|Mission"          # Tests matching a name regex
ctest --test-dir build --rerun-failed               # Re-run only failed tests
ctest --test-dir build --output-junit results.xml   # JUnit XML report
```

### Via `just`

```bash
just test                                    # Labels "Unit|Integration", excludes "Flaky|Network"
LABELS=Unit just test                        # Override labels
EXCLUDE=Network just test                    # Override exclusions
LABELS=Slow EXCLUDE=Flaky just test          # Both at once
JOBS=4 just test                             # Override parallelism (default: all cores)
```

`just test` runs `ctest --preset default --build-config <BUILD_TYPE> --parallel <JOBS> --no-tests=error -L
"<LABELS>" -LE "<EXCLUDE>"`; the test preset (`cmake/presets/common.json`) turns on output on failure. The `default`
test preset tests `build/` only; for a preset with its own build directory, run `ctest --preset <name>` (for example
`Linux-sanitizers`).

CI runs the `Unit` and `Integration` labels as separate CTest runs with the same exclusions. Linux CI uses one
process per CPU (four on its runners), except two for coverage integration tests; to match it, run
`ctest --test-dir build -L Integration -LE 'Flaky|Network' --output-on-failure --parallel 4`. Settings and
temporary directories are isolated per test, and local network fixtures allocate ephemeral ports. CTest
`RESOURCE_LOCK` still serializes tests sharing hardware or fixed resources. `SERIAL` tests get `RUN_SERIAL` only in
sanitizer (`QGC_ENABLE_ASAN`/`UBSAN`/`TSAN`/`MSAN`) and coverage builds; other builds run them alongside other
tests with `PROCESSORS 2`. Preserve this isolation when adding integration tests.

### Via the QGroundControl Binary (unittest build)

The binary is `build/Debug/QGroundControl` on Linux (`QGroundControl.app/Contents/MacOS/QGroundControl` on macOS,
`QGroundControl.exe` on Windows).

```bash
./QGroundControl --list-tests                          # List all registered tests
./QGroundControl --list-tests --label=Unit,Utilities   # List tests matching labels
./QGroundControl --unittest:MyTest                     # Run one test suite
./QGroundControl --unittest                            # Run all tests
./QGroundControl --unittest --label=Unit               # Run by label filter (comma-sep)
./QGroundControl --unittest:MyTest --unittest-output:results.xml  # JUnit XML in results-MyTest.xml
./QGroundControl --unittest:MyTest --unittest-stress:50           # Stress: 50 iterations
./QGroundControl --unittest:MyTest --unittest-shard:2/3           # Every 3rd test function, starting at the 2nd
                                                                  # (results-MyTest.2.xml with --unittest-output)
```

`--unittest:<Class>` runs every function of the class; there is no single-function selector.

### Convenience Targets

Each target builds the application first (`cmake --build build --target <target>`):

```text
check                # All tests
check-unit           # Unit tests only
check-integration    # Integration tests, one at a time
check-fast           # Exclude Slow
check-ci             # Exclude Flaky and Network
check-flaky          # Repeat until-fail (QGC_TEST_FLAKY_REPEAT, default 3) to surface flaky failures
check-missionmanager # One label: also check-vehicle, check-utilities, check-mavlink, check-comms
```

### QML Test Split

There are two separate QML-related test entry points with different purposes:

- `QmlQuickTests` (recommended for real QML testing): Runs `tst_*.qml` via a dedicated
  Qt Quick Test executable (`QGCQmlQuickTests`) out-of-process.
- `QmlTestFileValidator` (sanity/structure check): Runs inside the regular `UnitTest`
  harness and validates test discovery/setup, but is not the primary Qt Quick execution
  path.

```bash
# Real QML quick tests
ctest --test-dir build -R QmlQuickTests --output-on-failure

# In-process sanity check runner
ctest --test-dir build -R QmlTestFileValidator --output-on-failure
```

## Portable Utility Tests

Windows x64 and the macOS Release CI leg enable `QGC_BUILD_PORTABLE_TESTS` alongside the normal
application build (the macOS Debug leg runs the Unit and Integration suites instead, same as
Linux). The ten `Portable.*` CTest entries use small Qt Test executables, with the same test
bodies and production utility libraries as the Linux application tests. They require no
QGCApplication, vehicles, or QML engine. The portable command-line parser target enables
its own test hooks; the packaged application keeps its normal build configuration.

```bash
cmake -S . -B build -DQGC_BUILD_PORTABLE_TESTS=ON
cmake --build build --target portable-tests --parallel 8
ctest --test-dir build --build-config Debug --output-on-failure -L Portable
```

Use `PortableTest` and `QGC_REGISTER_PORTABLE_TEST` for these suites. Full application
builds use the existing UnitTest harness; standalone executables use Qt Test with warnings
failing tests. Keep application-dependent fixtures in the full harness.

## GPS Protocol Golden Transcripts

`GPSGoldenTranscriptTest` pins the native GPS receiver protocols on a virtual clock. For each
scenario it records host-to-receiver bytes, baud changes, configuration evidence, identity and
decoded events. It also records the events decoded from every file in `test/GPS/Core/Protocols/corpus/`
and `test/GPS/Core/Protocols/fixtures/` at several chunk sizes. The expected transcripts live in
`test/GPS/Core/Protocols/golden/`, and `test/GPS/Core/Protocols/Support/GoldenTranscript.h` documents the driver
seam. The suite also checks that a decoder armed without I/O for a recording replay
(`GPSProtocolRuntime::armDecodeOnly()`) decodes those files as the configured decoder does.
[`test/GPS/Core/Protocols/GPSGoldenTranscriptTest.cc`](GPS/Core/Protocols/GPSGoldenTranscriptTest.cc) describes the
format. A scenario that behaves exactly as another one names it in `sameAs` and has no golden of its own, and a
scenario records its stream phase only when that differs from every other scenario's of its family.

A mismatch prints a diff hunk. Rewrite the goldens only for a deliberate, justified behaviour
change, then review the diff. Update mode also removes files no scenario produces:

```bash
QGC_GPS_GOLDEN_UPDATE=1 ctest --test-dir build -R GPSGoldenTranscriptTest
```

`test/GPS/Standalone` builds `QGCGPSCore` and `QGCNTRIPHttp` without the application, with the receiver
qualification runner and the NTRIP decoder smoke test. With Clang, `-DQGC_GPS_LIBFUZZER=ON` adds the protocol and
NTRIP libFuzzer harnesses and builds both libraries with ASan and UBSan:

```bash
cmake -S test/GPS/Standalone -B build-gps -DCMAKE_CXX_COMPILER=clang++ -DQGC_GPS_LIBFUZZER=ON
cmake --build build-gps && ctest --test-dir build-gps
```

## MultiSignalSpy

```cpp
MultiSignalSpy spy;
spy.init(myObject);

// String-based API (recommended)
QVERIFY(spy.emittedOnce("valueChanged"));           // Emitted exactly once
QVERIFY(spy.onlyEmittedOnce("valueChanged"));       // Only this signal fired
QVERIFY(spy.waitForSignal("valueChanged", TestTimeout::mediumMs())); // Wait with timeout
QVERIFY(spy.notEmitted("errorOccurred"));           // Signal not emitted
spy.clearAllSignals();

// Fluent expectation API
QVERIFY(spy.expect("signal").once());
QVERIFY(spy.expect("signal").atLeastOnce());
QVERIFY(spy.expect("signal").never());
QVERIFY(spy.expect("signal").times(3));

// Argument extraction
int value = spy.argument<int>("valueChanged");

// Multiple-signal API (each signal emitted exactly once)
QVERIFY(spy.emittedOnce("signal1", "signal2"));
```

## Manual Test Plans

`plans/` holds `.plan` files for manually checking 2D/3D mission display. There is one for each
MockLink home location and PX4/ArduPilot vehicle type. Each plan mixes altitude frames
(relative, AMSL, calculated above terrain, and terrain frame on ArduPilot) and item types over
~3 km legs, so the paths cross terrain. The plans exercise the UI and aren't meant to be flown.
Start a MockLink with the matching home location, then load the plan in Plan View and upload it.

`PlanMasterControllerTest::_testManualTestPlansLoad` loads every plan, so they stay loadable as the
plan format changes. Regenerate them after changing the generator:

```bash
test/plans/generate_test_plans.py
```

## Code Coverage

```bash
just coverage   # Build with coverage, run tests, generate HTML + XML report
```

`just coverage` configures `build/Linux-coverage/` with the `Linux-coverage` preset (line tables and unity build, like CI)
and runs CI's two test phases through its test preset (excluding `Flaky|Network`): `Unit` at one process per CPU,
then `Integration` at `-j2`.
See [tools/README.md](../tools/README.md#coverage_reportpy) for the `coverage_report.py` wrapper flags and the
underlying CMake `coverage*` targets.

## Sanitizers

Linux CI runs the `Unit` and `Integration` suites with ASan + UBSan through the `Linux-sanitizers` preset, which
also enables LSan leak detection (`QGC_TEST_DETECT_LEAKS=ON`) and excludes `NoSanitizer` tests. Reproduce it with
`QT_ROOT_DIR` pointing at the Qt installation:

```bash
cmake --workflow --preset Linux-sanitizers   # Configure, build and test in build/Linux-sanitizers
ctest --preset Linux-sanitizers -R MyTest    # Re-run one test after a rebuild
```

ThreadSanitizer can't be combined with ASan; CI runs it only from a manual Analysis workflow dispatch:

```bash
cmake --preset Linux-debug -B build-tsan -DQGC_ENABLE_TSAN=ON
cmake --build build-tsan && ctest --test-dir build-tsan -L "Unit|Integration" -LE "Flaky|Network|NoSanitizer"
```

`cmake/QGCTest.cmake` sets the enabled sanitizers' `*SAN_OPTIONS` (each with `halt_on_error=1`, plus the
suppression files generated in the build directory) on every CTest test, replacing any value in your shell. Run the
test binary directly to try other options. `QGC_TEST_DETECT_LEAKS` is `OFF` outside the
preset because LSan needs `ptrace` permission, which containers and Yama often deny.

## Debugging Test Failures

### Enable Verbose Output

```bash
QGC_TEST_VERBOSE=1 build/Debug/QGroundControl --unittest:MyTest
```

### Common Issues

1. **Test timeout**: Check for blocking operations, increase timeout with `TIMEOUT`
2. **Resource conflicts**: Use `RESOURCE_LOCK` for exclusive access (`SERIAL` runs a test alone
   only in sanitizer and coverage builds)
3. **Flaky signals**: Use `MultiSignalSpy::waitForSignal()` instead of `QSignalSpy::wait()`
4. **Stale singletons**: Ensure proper cleanup in `cleanup()` method
5. **Unexpected log messages (strict mode)**: Every test runs with strict log checking, so
   any log message that is not explicitly expected or suppressed causes `QFAIL`. If the
   code under test emits a warning you didn't anticipate, use the QGC base-class helpers
   instead of `QTest::ignoreMessage` (which is banned by the `check-no-qtest-ignore-message`
   pre-commit hook):

   ```cpp
   // Preferred: one-shot expectation — asserts the message was actually emitted
   expectLogMessage("MAVLink.LibEvents.HealthAndArmingCheckReport",
                    QtWarningMsg,
                    QRegularExpression(QStringLiteral("Flight mode group not set")));
   somethingThatLogs();
   verifyExpectedLogMessage();

   // Last resort only: persistent suppression — does NOT assert the message fires
   ignoreLogMessage("qt.bluetooth",
                    QtWarningMsg,
                    QRegularExpression(QStringLiteral("Cannot find a compatible running Bluez")));
   ```

   `expectLogMessage(...)` must be followed by `verifyExpectedLogMessage()`.
   If verification is omitted, `cleanup()` fails the test.

   **Always prefer `expectLogMessage` + `verifyExpectedLogMessage`.** That
   pairing both silences the strict-mode check *and* asserts the message was
   actually emitted, so a regression that removes the log will break the test.

   Use `ignoreLogMessage(...)` only as a last resort for non-deterministic or
   environment-dependent noise that cannot be tied to a precise call site.

6. **Expensive SKIP paths**: If a test conditionally `QSKIP`s based on a backend probe
   (e.g. "SDL didn't expose a second virtual joystick"), cache the probe result in a
   test-class member so subsequent skipping tests don't each burn a full timeout.
   See `JoystickManagerTest::_multiJoysticksSupported()` for the pattern.

7. **Skipping in `init()`**: a `QSKIP` in `init()` also skips `cleanup()`, and `UnitTest`
   requires `cleanup()` once its `init()` has run. Skip before calling `UnitTest::init()`, or
   skip inside the test function.

---

### Overview (`tools/README.md`)

# QGroundControl Tools

Development tools, scripts, and configuration files for QGroundControl. This is the full
reference for every `just` recipe and standalone script; [AGENTS.md](../AGENTS.md) has the
condensed day-to-day workflow.

## Table of Contents

- [Quick Start](#quick-start)
- [Directory Structure](#directory-structure)
- [Just Command Reference](#just-command-reference)
- [Direct Script Usage](#direct-script-usage)
- [Development Scripts](#development-scripts)
- [Setup Scripts](#setup-scripts)
- [Python Environment](#python-environment)
- [Python Script Conventions](#python-script-conventions)
- [Static Analyzers](#static-analyzers)
- [Debugging Tools](#debugging-tools)
- [Other Tool Areas](#other-tool-areas)
- [Code Quality Tools](#code-quality-tools)
- [VS Code Integration](#vs-code-integration)
- [Centralized Configuration](#centralized-configuration)

## Quick Start

Install uv, then the locked `dev` Python profile into `tools/.venv`; the profile includes `just`:

```bash
# Linux/macOS; on Windows install uv from https://docs.astral.sh/uv/getting-started/installation/
python3 tools/setup/install_uv.py
export PATH="$HOME/.local/bin:$PATH"

python3 tools/setup/install_python.py dev   # Windows: python tools/setup/install_python.py dev
. tools/.venv/bin/activate                  # PowerShell: . tools/.venv/Scripts/Activate.ps1
```

A separately installed `just` (`brew install just`, `cargo install just`) must be 1.48 or newer
because the justfile uses `set lazy`; Ubuntu's `apt install just` ships 1.21, which is too old.

Everyday loop:

```bash
just             # List all recipes
just setup       # First-time: deps + configure + build
just build       # Incremental build
just check       # lint + Python tooling tests + application tests — run before declaring done
```

## Directory Structure

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Just Command Reference

All recipes are defined in [`../justfile`](../justfile), grouped by the sections below; run `just`
with no arguments for the same list. Most recipes run Python through
`uv run --frozen --project tools`. `BUILD_TYPE` (default `Debug`), `JOBS` (default: all cores),
and `QT_DIR` override the build recipes; `just info` prints them with the Qt, CMake, and GStreamer
versions from `.github/build-config.json` ([Centralized Configuration](#centralized-configuration)).

### Setup

| Recipe            | Description                                                    |
| ----------------- | -------------------------------------------------------------- |
| `just deps`       | Auto-detect and install system build dependencies              |
| `just vscode`     | Install missing VS Code workspace files from tracked templates |

### Build

| Recipe              | Description                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `just configure`    | Configure through the matching `default*` CMake preset (Debug by default)                        |
| `just build`        | Build the project (uses all cores by default; override with `JOBS=N`)                            |
| `just release`      | Configure and build in Release mode (testing disabled)                                           |
| `just clean [ARGS]` | Clean the build directory; forwards `ARGS` to `tools/clean.py` (`--cache`, `--all`, `--dry-run`) |
| `just rebuild`      | `clean` + `configure` + `build`                                                                  |
| `just setup`        | Full first-time setup: `deps` + `configure` + `build`                                            |

### Quality

| Recipe                  | Description                                                                                           |
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
| `just test`             | Run application tests via CTest; defaults to labels `Unit`/`Integration`, excluding `Flaky`/`Network` |
| `just test-python`      | Run the Python tooling tests (`tools/tests`, incl. `tools/tests/ci`) in parallel, in random order     |
| `just lint [ARGS]`      | Run non-manual hooks on all files with prek (`prek run --all-files`); `ARGS` select hooks/stages      |
| `just lint-docs [ARGS]` | Lint English docs with only the docs hooks (every page; `--changed` for branch changes)               |
| `just format`           | Check clang-format on lines changed since the default branch (no changes)                             |
| `just format-fix`       | Apply clang-format to lines changed since the default branch                                          |
| `just analyze`          | Run static analysis (`tools/analyze.py`, default tool clang-tidy)                                     |
| `just coverage`         | Build with coverage instrumentation, run tests, generate report                                       |
| `just check`            | `lint` + `test-python` + `test` — run before declaring a task done                                    |

Override `test` label filters via environment variables or positional args:

```bash
just test                              # LABELS=Unit|Integration EXCLUDE=Flaky|Network (defaults)
LABELS="Slow" just test                # override via env var
just test "Slow" "Network"             # override via positional args (labels, exclude)
```

### Run & Deploy

| Recipe        | Description                                                                    |
| ------------- | ------------------------------------------------------------------------------ |
| `just run`    | Launch the built `QGroundControl` binary                                       |
| `just docs`   | Build the VitePress documentation site (`npm run docs:build` in `docs/`)       |
| `just docker` | Build the `ubuntu` Docker variant (`deploy/docker/run_docker.py build ubuntu`) |

### Utilities

| Recipe                   | Description                                                                                           |
| ------------------------ | ----------------------------------------------------------------------------------------------------- |
| `just info`              | Print resolved build configuration (Qt version/dir, CMake min, GStreamer, jobs, ...)                  |
| `just check-deps`        | Check Qt, GStreamer, Python, and build tool versions (`tools/check_deps.py`)                          |
| `just check-gstreamer`   | Check the latest GStreamer patch common to all SDK platforms                                          |
| `just translations`      | Update translation sources (`tools/translations/qgc_lupdate.py`)                                      |
| `just distclean`         | Clean build, caches, generated files, and `node_modules/`                                             |
| `just validate-configs`  | Validate build settings and link exceptions ([Centralized Configuration](#centralized-configuration)) |
| `just release-notes TAG` | Print draft release notes for a `vX.Y.Z` tag ([Release notes](#release-notes))                        |
| `just lint-qml-build`    | Lint all QML against the build's generated type information ([analyze.py](#analyzepy))                |

## Direct Script Usage

Prefer `just` recipes; call the scripts below directly for flags a recipe doesn't expose. Run them
from the repository root with `tools/.venv` activated ([Quick Start](#quick-start)), or prefix the
command with `uv run --frozen --project tools --group dev` instead. The `dev` profile covers every
script here.

Repository configure/build/test/workflow presets are rooted at [`../CMakePresets.json`](../CMakePresets.json).
Use `CMakeUserPresets.json` for machine-local paths or derived presets; it is ignored by Git and
preserved by `just clean`. `tools/configure.py` (behind `just configure`) requires the Qt kit's
`qt-cmake`, found through `--qt-root` (`QT_DIR` for `just`), `QT_ROOT_DIR`, or the newest standard
Qt install for the host architecture. Use `--no-qt-cmake` only when supplying an explicit target
toolchain, as Android CI does. `configure.py` configures into the selected preset's `binaryDir`,
where `cmake --build --preset` and `ctest --preset` look for the tree; `-B` overrides it.

## Development Scripts

### analyze.py

Run static analysis and formatting on source code. Underlies `just format`, `just format-fix`,
`just analyze`, and `just lint-qml-build`.

```bash
python tools/analyze.py                              # Analyze changed files (default tool: clang-tidy)
python tools/analyze.py --all                        # Analyze all files
python tools/analyze.py --tool clang-format --fix    # Format changed lines
python tools/analyze.py --tool clang-format --all    # Check formatting (all files)
python tools/analyze.py src/Vehicle/                 # Analyze specific directory
python tools/analyze.py --tool clang-tidy --tool clazy --cache-dir .cache/analysis-results  # Both, one scan
python tools/analyze.py --tool clang-tidy --all --shard 1 --shard-count 4  # One of four disjoint partitions
python tools/analyze.py --tool clang-tidy --profile-checks src/Vehicle/Vehicle.cc  # Slower per-check profiling
python tools/analyze.py --tool clang-tidy --fix src/Vehicle/  # Apply clang-tidy fixes (local only)
```

Paths are relative to the repository root; absolute paths inside the checkout also work.

Other `--tool` choices: `clazy`, `qmllint`, `qmlformat` (reports what `qmlformat` would change,
never rewrites), `vehicle-null-check`, `qt-translate-noop-check`.
Without paths or `--all`, files changed since the merge base with the default branch (or
`PR_BASE_SHA`; in GitHub's merge checkout of `PR_HEAD_SHA`, its first parent) are analyzed,
including uncommitted and untracked files; without a usable base every file is. For clang-format the
selection narrows to changed lines, matching the pre-commit hook: deletion-only changes are skipped
and new or untracked files are checked in full. `--all` or explicit paths check (or `--fix`) whole
files for intentional reformatting. `--advisory` keeps warnings informational; error-level
diagnostics and tool or compiler invocation errors still fail.

**Compiler analysis** (clang-tidy, Clazy) reads `compile_commands.json` from `--build-dir`
(default `build`) and needs generated headers, not a full build. Configure Ninja with
`CMAKE_EXPORT_COMPILE_COMMANDS=ON`, `CMAKE_DISABLE_PRECOMPILE_HEADERS=ON`,
`CMAKE_AUTOGEN_ORIGIN_DEPENDS=OFF`, `CMAKE_GLOBAL_AUTOGEN_TARGET=ON`, and `QGC_UNITY_BUILD=OFF`,
then build only `cmake --build <dir> --target qgc-analysis-headers` (`autogen` is needed only for
IWYU). Repeating `--tool` shares one file selection and dependency scan; `--cache-dir <dir>`
replays unchanged results and needs `clang-scan-deps`. `tools/setup/build_clazy.py` builds the
Clazy that CI uses. `--tool clang-tidy --fix` applies the selected translation units' fixes with
`clang-apply-replacements` ([analyzers/README.md](analyzers/README.md#clang-tidy-fixes)); CI never
applies fixes. The clang-tidy and Clazy pre-commit hooks run only with `--hook-stage manual`;
fast hooks remain the required PR gate. CI sharding and result caching are described in
[ci-overview.md](../.github/ci-overview.md); output files and profiling in
[analyzers/README.md](analyzers/README.md).

**QML:** `--tool qmllint --qml-build` checks QML against the generated `<build-dir>/qml` type
information after a build. Missing imports, properties, and types are errors; the fast
source-only hook keeps its limited checks. `just lint-qml-build` runs it on every QML file with
`--advisory`.

### pre_commit.py

Runs the [`.pre-commit-config.yaml`](../.pre-commit-config.yaml) hooks with
[prek](https://github.com/j178/prek) and CI-friendly summaries; the pre-commit workflow calls it
with `--ci --changed --results-dir`. A `--changed` run whose docs lint configuration changed also
runs the docs hooks over every English page.

```bash
python tools/pre_commit.py --changed                             # Files changed vs PR_BASE_SHA or the default branch
python tools/pre_commit.py --changed --hook ruff --hook pyright  # Only these hooks (repeatable)
python tools/pre_commit.py --docs --changed                      # English docs with only the docs hooks (just lint-docs)
python tools/pre_commit.py --changed --needs-qt                  # Whether the selection has QML (qmllint needs Qt)
python tools/pre_commit.py --install                             # Install the pre-commit and commit-msg hooks
python tools/pre_commit.py --prepare-cache                       # Install the hook environments CI caches, prune the rest
```

The selected hooks run in one `prek run` call; its output streams to the console and, with
`--results-dir`, into `pre-commit-output.txt`. `--ci` skips the local-only `trufflehog` hook unless
`SKIP` is already set. `--prepare-cache` installs every hook environment except
`CACHE_SKIPPED_HOOKS` (TruffleHog and the Renovate validator) and then runs `prek cache gc`; the
pre-commit workflow runs it on pushes that miss the prek cache, and saves the cache only when the
install succeeded.

The `precommit` group pins prek, which also accepts pre-commit's commands and options
(`prek run <id> --hook-stage manual --files <paths>`, `SKIP=<id>`). The configuration stays
compatible with stock pre-commit, which the group keeps as a fallback runner for one release.
`editorconfig-checker` enforces [`.editorconfig`](../.editorconfig) with the exclusions and relaxed
checks in `configs/editorconfig-checker.json`; `cmake-presets-schema` validates `CMakePresets.json` and
`cmake/presets/*.json` offline against the schema shipped in the locked `cmake` package.
Manual-stage hooks run only on request: `clang-tidy`, `clazy`, the CI validators that download pinned
tools (`renovate-config-validator`, `packer-validate`, `runs-on-lint`, which `pre_commit.py --ci` also
runs on the changed files), and
three formatter trials that report differences without rewriting files: `gersemi` (CMake,
`configs/gersemirc.yaml`), `yamlfmt` (`configs/yamlfmt.yaml`), and `qmlformat` (`analyzers/qmlformat.py`,
`.qmlformat.ini`). For example, `just lint gersemi --hook-stage manual` checks every CMake file.

### clean.py

Clean build artifacts and caches. Underlies `just clean` and `just distclean`.

```bash
python tools/clean.py              # Clean build directory
python tools/clean.py --all        # Clean everything (build, caches, node_modules/, Python bytecode outside venvs)
python tools/clean.py --cache      # Clean only repo-local caches (.cache, incl. ccache and the clangd index, plus a legacy .ccache)
python tools/clean.py --dry-run    # Show what would be removed
```

### build_profile.py

Summarize build-time hotspots from Ninja logs and optional Clang time traces. The parsing and report
code lives in `qgc_tools/build_profile.py`, which `ci/compile/cmake_helper.py` and
`ci/compile/docker_helper.py` also use.

```bash
python tools/build_profile.py -B build                 # Report slowest Ninja edges and rebuild churn
python tools/build_profile.py -B build --limit 25      # Show more rows per section
python tools/build_profile.py -B build --json          # Machine-readable output
python tools/build_profile.py -B build --json-out r.json > r.txt  # Text and JSON from one scan
python tools/build_profile.py -B build --output-dir build/profile  # Ninja-only snapshot
python tools/build_profile.py -B build --step-summary --extra-report "Title=other.txt"  # Plus a job-summary section
python tools/build_profile.py --install-clang-build-analyzer .cache/ClangBuildAnalyzer  # Pinned binary (Linux x86_64)
```

For per-translation-unit trace details, configure with `-DQGC_TIME_TRACE=ON`, rebuild, then rerun
the report — it scans the build dir for Clang `-ftime-trace` JSON and highlights the slowest events.

Ordinary CI builds upload Ninja-only snapshots with separate link and generated-code rankings.
Task totals overlap because commands run in parallel; they are not elapsed build time. The manual
Build Profile workflow installs the ClangBuildAnalyzer release pinned (with its SHA-256) in
`.github/build-config.json`'s `tools.clang_build_analyzer`, and `--step-summary` writes its job
summary: build type, commit, and compiler, with this report and each `--extra-report` collapsed.

### moccache/

Content-addressed cache for Qt's moc, wired in through `AUTOMOC_EXECUTABLE` while the
`QGC_USE_MOCCACHE` CMake option is ON (the default). The generated launcher runs
`moccache_launcher.py`, which imports the `moccache` package so its bytecode is cached in
`tools/moccache/__pycache__`. A moc run loads only `core.py`; `trim.py` (LRU eviction) and `stats.py`
(hit/miss reports) are imported only for their subcommands or a due auto-trim, keeping each cache
hit's startup short. The stats and trim commands run through the launcher too
(`python3 tools/moccache_launcher.py --show-stats ...`).
Misses fall through to the real moc and never fail the build. The
[dev guide Build Caching section](../docs/en/qgc-dev-guide/getting_started/index.md#build-caching)
covers the cache key, environment variables, and stats commands. Tests:
`tools/tests/test_moccache.py`.

### coverage_report.py

Generate code coverage reports through the CMake coverage targets. Underlies `just coverage`.
Configures the `Linux-coverage` preset's `build/Linux-coverage/` directory (override the directory
with `-b`/`--build-dir`). Requires `gcovr` (`coverage` group, included in `dev`).
Coverage compiles pass `-fprofile-update=atomic`, which ccache caches only from 4.10; an older
distro ccache (Ubuntu 24.04 ships 4.9.1) gets no cache hits in coverage builds.
Reports are Cobertura `coverage.xml` (uploaded to Codecov), gcovr's JSON `coverage-summary.json`
(read by the step summary, `verify_coverage_thresholds.py`, and the PR build-results comment), and
HTML `coverage.html`. Reporting fails when the build directory holds no `.gcda` counters, so tests
that wrote no coverage data cannot publish a zero-coverage report.

```bash
python tools/coverage_report.py              # Build with coverage, run tests like CI, generate XML, JSON + HTML
python tools/coverage_report.py --report     # Generate report only from existing data (CI adds --step-summary)
python tools/coverage_report.py --open       # Full run, then open the HTML report
python tools/coverage_report.py --clean      # Delete the coverage build directory
```

**Direct CMake usage:**

```bash
cmake -B build -DQGC_ENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build
cmake --build build --target coverage-report  # XML, JSON summary + HTML from existing data
cmake --build build --target coverage         # Run Unit tests + generate XML, JSON summary + HTML
cmake --build build --target coverage-clean   # Remove coverage data and reports
```

`coverage_report.py` runs CI's two test phases through the `Linux-coverage` test preset, which
excludes `Flaky` and `Network`: Unit tests at one process per CPU, then Integration tests at `-j2`,
because instrumented integration tests time out at full parallelism. The CMake `coverage` target
runs only the Unit tests.

### check_deps.py

Check for outdated dependencies. Underlies `just check-deps`.

```bash
python tools/check_deps.py              # Check all dependencies
python tools/check_deps.py --qt         # Check Qt version
python tools/check_deps.py --gstreamer  # Find latest patch common to all SDK platforms
```

### API documentation (Doxygen)

Run Doxygen from the repository root, as the Doxygen workflow does; HTML goes to `docs/api/html/`.
The workflow builds and deploys weekly; pull requests run it only when they change the Doxyfile or
the workflow, because warnings do not fail the build. Master runs keep the HTML artifact for eight
days, and the Pages workflow publishes the latest under <https://mavlink.github.io/qgroundcontrol/api/>.

```bash
doxygen tools/configs/Doxyfile
```

Requires `doxygen`; `graphviz` enables diagrams.

### Release notes

Release tags are created and pushed by hand. `just release-notes vX.Y.Z` prints the notes that
`release.yml` drafts for that tag: git-cliff (`release` group) lists the commits since the previous
`vX.Y.Z` tag reachable from it, grouped by type as configured in `configs/cliff.toml`, as full
Conventional Commits subjects under `## Changes in vX.Y.Z`. For a tag that does not exist yet it
lists the unreleased commits since the last release reachable from `HEAD`.

```bash
just release-notes v5.1.5                                # Notes for an existing tag
python tools/ci/publish/release_notes.py v5.2.0          # Same script; v5.2.0 not tagged yet
```

### PX4 metadata

`px4-metadata.yml` runs `update_px4_metadata.py` daily and opens a pull request when the bundled PX4
airframe (`AirframeFactMetaData.xml`) or parameter (`PX4ParameterFactMetaData.json`) metadata
changes upstream. It retries both downloads from artifacts.px4.io and replaces neither file unless
both are valid: well-formed airframe XML without a DTD, and xz-compressed JSON that lists parameters.

```bash
python3 tools/update_px4_metadata.py
```

## Setup Scripts

Scripts in `setup/` configure development environments and read versions from
`.github/build-config.json`. They need only the standard library, so they also run with the host
`python3` before `tools/.venv` exists. `install_dependencies` is a package
(`tools/setup/install_dependencies/`) run through its `__main__.py`; without `--platform` it
detects the host. `build_gstreamer.py` is the entry for the `setup/gstreamer/` package: `config`
(build settings, Meson options and plugins), `host` (commands, host arch and jobs), `meson` (Linux,
macOS, Windows), `cerbero` (Android, iOS) and `cli` (arguments, CI defaults and arch/prefix
outputs). The Build GStreamer workflow first runs `setup/gstreamer/ci_plan.py`, which rejects an
architecture the dispatch's runner cannot build and outputs the runner's Qt and apt category.

| Script                                    | Platform              | Description                                                                            |
| ----------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
| `install_dependencies --platform debian`  | Linux (Debian/Ubuntu) | Install build dependencies via apt                                                     |
| `install_dependencies --platform fedora`  | Linux (Fedora/RHEL)   | Install build dependencies via dnf                                                     |
| `install_dependencies --platform arch`    | Linux (Arch)          | Install build dependencies via pacman                                                  |
| `install_dependencies --platform macos`   | macOS                 | Install dependencies via Homebrew + GStreamer                                          |
| `install_dependencies --platform windows` | Windows               | Install GStreamer (optional `--vulkan`, `--msvc`, `--msvc-arm64`, `--nsis`)            |
| `install_uv.py`                           | Linux, macOS          | Install the uv release named by `required-version` when `uv` is not on `PATH`          |
| `install_python.py`                       | All                   | Install locked Python tool groups via uv ([Python Environment](#python-environment))   |
| `install_qt.py`                           | All                   | Install Qt with [QGC's installer or aqtinstall](#qt-installer) (CI: `plan`/`ensure`)   |
| `setup_vscode.py`                         | All                   | Install missing VS Code workspace files from tracked templates                         |
| `read_config.py`                          | All                   | Read `.github/build-config.json`                                                       |
| `build_gstreamer.py`                      | All                   | Build GStreamer from source (optional)                                                 |
| `build_android_openssl.py`                | Android               | Cross-compile OpenSSL for Android; configure builds only the build type's variant      |
| `build_clazy.py`                          | Linux                 | Build the pinned `clazy-standalone` against the analysis LLVM, as CI does              |

Linux `--category` limits the install to `core`, `qt`, `gstreamer`, `sdl`, `audio` or `misc`.
On Debian, `android` is `core` plus the OpenJDK for `android.java_version` in `build-config.json`;
the Docker Android image uses it, while CI's Android jobs install `core` and get the JDK from
`setup-java`. Debian's `gstreamer_build` and `gstreamer_build_android`, outside the full set, hold
what `build_gstreamer.py` needs for a Linux source build and an Android (Cerbero) build; the Build
GStreamer workflow installs them.
`--list [--platform NAME]` lists each platform's packages by category and
`--print-packages [--category NAME]` prints one list on a single line; neither installs anything.
Locally, the Linux installers also sync the locked `build` group (CMake, Ninja, `just`, ...) into
`tools/.venv` and print how to activate it; CI build jobs instead use the `build.cmake_version` and
`build.ninja_version` pins in `build-config.json`, which `build-setup` installs.

On macOS, the GStreamer runtime and development packages come from the official server, falling
back to QGroundControl's public S3 mirror. Both sources are verified against the SHA-256 checksums
pinned in `.github/build-config.json`; installation stops if either package cannot be verified.

```bash
# Linux: Install all dependencies
python3 tools/setup/install_dependencies --platform debian

# Linux: build tools plus Gradle's JDK only, for Android builds
python3 tools/setup/install_dependencies --platform debian --category android

# macOS: Install all dependencies
python3 tools/setup/install_dependencies --platform macos

# Windows (as Admin):
python .\tools\setup\install_dependencies --platform windows

# Install Qt from the configured version and modules
python3 tools/setup/install_qt.py install --from-config --host linux --arch linux_gcc_64

# Build GStreamer from source (optional — CMake auto-downloads pre-built SDKs)
python3 tools/setup/install_dependencies --platform debian --category gstreamer_build
python3 tools/setup/build_gstreamer.py --platform linux --prefix /opt/gstreamer

# Build Clazy like the Code Analysis workflow; then put .cache/clazy/bin on PATH
python3 tools/setup/build_clazy.py --prefix .cache/clazy

# Read build config values
python3 tools/setup/read_config.py --get qt.version
```

### Qt installer

`install_qt.py` installs Qt with one of two tools, chosen by `--installer` or `QGC_QT_INSTALLER`:

- `qgc`, the default: the standard-library package `setup/qt_installer/`. It reads the
  combination's `Updates.xml` from download.qt.io, checks it and every archive against the SHA-256
  sums that download.qt.io publishes (archives may come from any mirror), extracts the archives in parallel with
  `cmake -E tar`, and applies aqtinstall's post-install edits. CMake comes from `PATH`, else from
  the tools environment's `build` group. The Qt root gets `.qgc-qt-manifest.json` (archives,
  checksums, patched files) and, for full installs, the `.qgc-modules` marker that preinstalled-SDK
  reuse reads.
- `aqt`: aqtinstall from the locked `qt` group, which stays as the fallback.

Both produce the same tree apart from install paths in patched text files and pkg-config `prefix=`
lines (aqt leaves them unpatched on `linux_arm64`); `install_qt.py compare` checks this for a
combination. Android built on a Windows host differs on purpose: `qgc` pairs it with the
`msvc2022_64` host Qt, while aqt names a `mingw_64` one that QGC never installs; builds pass
`QT_HOST_PATH` either way. Like aqt, `qgc` reports but does not install addon dependencies missing from
`--modules`, such as `qtquick3d`'s `qtquicktimeline`. The `plan` cache key's digest includes a non-aqt
installer, so neither tool restores the other's tree.

## Python Environment

`install_python.py` installs dependency groups defined in `tools/pyproject.toml`:
`scripts`, `precommit`, `test`, `qt`, `coverage`, `build`, `dev` (default), `lint`, `terrain`,
`simulation`, `release`, and `all`. Groups compose smaller profiles: `dev` includes the build, Qt,
lint, test, and release (git-cliff) tools. `build` adds CMake, Ninja, Meson, `just`, prek (with
pre-commit), and gcovr; `simulation` adds pymavlink for the `simulation/` MAVLink tools. The Pyright
hook also syncs `terrain` so it can type-check `tools/terrain`. `--list-groups` prints each group's packages. `[tool.uv] required-version` in
`tools/pyproject.toml` is the single uv version: CI, `install_uv.py`, and the Docker image install
exactly that floor, and older uv releases refuse the project (run `uv self update`).

All Python dependencies are resolved in `tools/uv.lock`. Setup requires uv and uses
`uv sync --frozen --inexact` into `tools/.venv`, so repeating it with a smaller group preserves
installed tools. Use `--replace` only to deliberately replace the profile; `--check` validates the
running interpreter's installed versions and Python/platform markers without installing. CMake,
VS Code, and CI use the same environment; CMake creates it with the `scripts` group when it is
missing (`QGC_AUTO_PYTHON_VENV`).

For an image-owned environment, set `QGC_TOOLS_PROJECT` to the directory containing
`pyproject.toml` and `uv.lock`, and `QGC_PYTHON_ENV` to the environment directory.
Docker uses `/opt/qgc-tools` and `/opt/qgc-venv`. Qt installs that fall back to aqtinstall use
the locked `qt` group ([Qt installer](#qt-installer)).

`common/` contains low-level utilities ([module index](common/README.md)); `qgc_tools/` holds
shared policy such as the Python environment, configure-preset selection, Docker variants, and
release-tag checks ([qgc_tools/README.md](qgc_tools/README.md)). Command-line entrypoints are the
top-level `tools/*.py` scripts and those in `setup/`, `ci/`, and `deploy/`. Import-linter enforces
these dependency boundaries: `ci` is the top layer, and `setup` sits beside `analyzers`,
`generators`, `terrain`, and `translations`, which must not import each other.

## Python Script Conventions

New and edited scripts under `tools/` follow one shape, built on the [`common/`](common/README.md)
helpers:

- **Entry**: scripts in subpackages first run the bare statement
  `sys.path.insert(0, str(Path(__file__).resolve().parents[N]))` (N levels below `tools/`);
  top-level scripts already have `tools/` on `sys.path`. Entry scripts then
  `import _bootstrap as _bootstrap`, which installs the CI debug hooks; the redundant alias marks
  the import as intentional. Library modules leave `sys.path` alone
  ([details](common/README.md#bootstrapping-imports)).
- **Main**: define `main(argv: list[str] | None = None) -> int` and end with
  `raise SystemExit(main())` under `if __name__ == "__main__":`, so tests can call `main([...])`.
- **Output**: log locally with `common.logging` (`log_info`, `log_warn`, `log_error`); in CI,
  report problems as `common.gh_actions` annotations (`gh_error`, `gh_warning`) and write step
  outputs and summaries with its `write_github_output` and `write_step_summary`. Build each
  job-summary section with `common.markdown.summary_section` (H3 title, facts table, collapsed
  details) and format numbers with `common.format` so every job reads the same way.
- **Subprocesses and network**: run commands through `common.proc` (`run_captured`, `run_tee`,
  `run_with_retry`), check required executables with its `require_tools` and poll with its
  `wait_until`, download with `common.net`, and call GitHub through `common.gh_actions.gh()`.
- **Files**: locate the checkout with `common.paths` (`REPO_ROOT`, `TOOLS_DIR`) rather than
  `Path(__file__).resolve().parents[N]` outside the entry statement, and read and write JSON with
  `common.io` (`read_json`, `write_json`).

## Static Analyzers

`analyzers/` holds the `analyze.py` backends, whose `clang_format.py` also runs as the
changed-region `clang-format` pre-commit hook and `qmlformat.py` as the manual-stage `qmlformat`
check, and two QGC-specific checks that also run as
pre-commit hooks: `vehicle_null_check.py` detects `activeVehicle()` results dereferenced without a
null check, and `qt_translate_noop_check.py` detects `QT_TRANSLATE_NOOP`/`QT_TR_NOOP` results used
as runtime strings. See [analyzers/README.md](analyzers/README.md) for usage and output formats.

## Debugging Tools

### debuggers/profile.py

Profile QGC for performance and memory issues.

```bash
python tools/debuggers/profile.py                # CPU profiling with perf
python tools/debuggers/profile.py --memcheck     # Memory leak detection (valgrind)
python tools/debuggers/profile.py --callgrind    # CPU profiling (valgrind)
python tools/debuggers/profile.py --massif       # Heap profiling (valgrind)
python tools/debuggers/profile.py --heaptrack    # Heap profiling (heaptrack)
python tools/debuggers/profile.py --sanitize     # Build with QGC_ENABLE_ASAN/UBSAN in build/sanitize/, then run
python tools/debuggers/profile.py --config Debug -- --logging:full  # Forward application arguments
```

### debuggers/android_mem_capture.py

Sample an Android app's memory (`dumpsys meminfo` PSS breakdown plus system `MemAvailable`) over
adb at a fixed interval and write a CSV, for soak testing and before/after comparisons. Handles
process restarts/LMK kills; press Enter during capture to drop a phase marker (POSIX terminals
only). Defaults to the QGC package.

```bash
python tools/debuggers/android_mem_capture.py --label geomap --interval 10   # Capture until Ctrl+C
python tools/debuggers/android_mem_capture.py --label soak --duration 30m    # Timed capture
python tools/debuggers/android_mem_capture.py --compare run-a.csv run-b.csv  # Summarize/compare runs
```

The compare summary reports start/peak/end and a second-half trend (kB/min) for total PSS,
graphics, native heap, and system available memory.

### Debugger Formatters and Suppressions

- `debuggers/gdb-pretty-printers/` — GDB pretty printers for Qt 6 types; see
  [its README](debuggers/gdb-pretty-printers/README.md) for setup.
- `debuggers/qt6.natvis` — Visual Studio debugger visualizers for Qt 6 types. Add it to your
  project's Natvis files, or point `visualizerFile` at it in a VS Code `cppvsdbg` launch
  configuration.
- `debuggers/valgrind.supp` — Valgrind suppressions used by `profile.py` and CTest memcheck.

## Other Tool Areas

These areas have their own READMEs:

- [ci/](ci/README.md) — the GitHub Actions entrypoints, in subpackages such as `planning/`,
  `cache/`, `compile/`, `publish/`, and `reporting/`; workflows run them by path, and their tests
  live in `tests/ci/` (workflow and action contracts in `tests/ci/contracts/`).
- [simulation/](simulation/README.md) — `mock_vehicle.py`, a lightweight MAVLink vehicle on
  UDP 14550, `run_arducopter_sitl.py`, ArduCopter SITL in Docker or from a local build on
  `tcp://localhost:5760`, `fly_plan.py`, which flies a `.plan` in ArduPilot or PX4 SITL and
  records the track and images, `mock_ntrip_caster.py`, an offline NTRIP caster on
  `127.0.0.1:2101` for testing RTK corrections, and `rc_transmitter.py`, a web page of RC channel
  sliders for PX4 SITL.
- [translations/](translations/README.md) — `just translations` runs `qgc_lupdate.py` (lupdate,
  the JSON string extractor, and `pseudo_loc.py`); `check_placeholders.py` guards translation
  placeholders.
- [terrain/](terrain/README.md) — `terrain_tiles.py` builds, validates, and uploads the
  terrarium elevation tiles GeoMap renders; it needs the `terrain` group.
- [generators/config_qml/](generators/config_qml/README.md) and
  [generators/settings_qml/](generators/settings_qml/README.md) — JSON schemas for the vehicle
  config and application settings pages CMake generates at configure time.

## Code Quality Tools

### ccache.conf

Configuration for [ccache](https://ccache.dev/). CMake's compiler launcher points ccache at
`tools/configs/ccache.conf` unless `CCACHE_CONFIGPATH` is already set; see the dev guide's
[Build Caching](../docs/en/qgc-dev-guide/getting_started/index.md#build-caching) section.

```bash
# Manual use:
export CCACHE_CONFIGPATH=/path/to/qgroundcontrol/tools/configs/ccache.conf
```

### coding-style/

Worked examples of [CODING_STYLE.md](../CODING_STYLE.md): `CodingStyle.h`, `CodingStyle.cc`, and
`CodingStyle.qml` ([coding-style/README.md](coding-style/README.md)).

## VS Code Integration

`.vscode/` tracks `extensions.json` (recommended extensions) and templates for `settings.json`
(editor and CMake integration), `tasks.json` (build, test, format tasks), and `launch.json` (debug
configurations) as `*.default.json`. `just vscode` copies missing ones without overwriting local
files. Open the repository in VS Code and install the recommended extensions.

## Centralized Configuration

Version numbers and build settings are centralized in `.github/build-config.json`, grouped into
`qt`, `android`, `apple`, `build`, `gstreamer`, and `analysis` objects (schema:
`.github/build-config.schema.json`). Scripts read it so local development and CI use the same
versions. Read a value with its dotted path, e.g. `read_config.py --get qt.version` or
`--get android.ndk_full_version`; exported env vars and CI outputs derive from the path
(`android.ndk_full_version` → `ANDROID_NDK_FULL_VERSION` / `android_ndk_full_version`).

Run `just validate-configs` after changing build versions, schemas, or link exceptions.
It validates required fields, minimum versions, SDK ordering, checksum coverage, and exception expiry.
The pre-commit gate triggers on schema-only edits too. External link exceptions in `configs/lychee.toml`
are exact URLs with a review deadline. Docs builds check English internal links without
exceptions; the all-locale build allows a finite list of existing translation links in
`docs/.vitepress/config.mjs`. CI's documentation checks are described in
[ci-overview.md](../.github/ci-overview.md).

---

### Overview (`src/GeoMap/README.md`)

# GeoMap

A custom 2D/3D map engine built on Qt Quick 3D, currently an experimental preview
alternative to QtLocation's `Map`. The ground is a mesh shaped by real elevation
data and draped with standard map tile imagery, viewed through a gesture-driven
camera that smoothly transitions between a classic top-down 2D map and a tilted 3D
view. Used by the FlyView (`FlyViewGeoMapAdapter.qml` / `FlyViewGeoMap.qml`).

## The big picture

The visible ground is made of square **patches** — slippy-map tiles chosen from a
quadtree so that patches near the camera are high-detail and distant ones are coarse.
Each patch needs two things:

- **Shape** (elevation): a grid of heights that displaces the patch mesh vertically.
- **Appearance** (imagery): a standard map tile image draped over the mesh.

Both are fetched cache-first through QGC's existing shared tile cache (network only
on a miss), so tiles downloaded for GeoMap are cached alongside normal map tiles and
vice versa.

As the camera moves, the set of patches is continuously refined: new detail is added
where the camera looks, off-screen patches are dropped, and everything happens
incrementally so the frame rate stays stable. The 2D↔3D switch is just an animation:
tilting the camera while scaling terrain displacement between 0 (flat 2D) and 1
(full 3D) — no geometry is rebuilt.

## Elevation: where the terrain shape comes from

Elevation data is the **AWS Open Data Terrain Tiles** dataset — worldwide elevation
encoded as "terrarium" PNGs, addressed exactly like map tiles (z/x/y, available up to
zoom 15; requests above z15 are satisfied from the z15 ancestor, so imagery keeps
refining past the elevation ceiling). `TerrariumTileFetcher` fetches them
cache-first: it checks QGC's shared tile database, falls back to the network on a
miss, and stores fetched tiles back into the cache. Every tile finer than z10 also
fetches the z10 ancestors of itself and its neighbors, and is held back until they
are in the field, so fine data doesn't land next to a region with no data at all
(which would render at 0 m and draw a cliff). A failed anchor stops holding it back:
fine data with a cliff risk beats no fine data.

Decoded tiles land in the `HeightField`, which presents **one continuous
heightfield**: it can answer "how high is the ground here?" for *any* position —
real data where a tile is present, an interpolated estimate from a coarser ancestor
tile elsewhere, and zero where nothing is known yet. This means a patch can always
be meshed immediately with the best current estimate; when better data arrives the
affected patches are re-meshed. There is never a hole in the terrain, only
temporarily-coarser terrain. Internally the `HeightField` keeps its tiles in an
`ElevationTilePyramid`, an in-memory LRU working set that resolves ancestor lookups
and pins tiles backing on-screen patches (and their neighbors) against eviction.

Patch edge vertices are resolved by position, so neighboring patches sample
bit-identical edge heights and never crack. Where an edge takes different data than
the patch's own (a neighbor's finer tile, say), the difference is blended across the
patch interior instead of dropping in the last cell, so it reads as a slope rather
than a cliff until both sides have the same data.

`TerrariumTileFetcher` implements the abstract `HeightSource` interface, which also
has synthetic implementations: `FlatHeightSource` (z = 0 everywhere, used when
terrain is disabled) and `DebugHeightSource` (analytic hills for verifying mesh
displacement and LOD offline).

## Imagery: where the map picture comes from

Patch imagery is the same provider tiles QGC uses everywhere else (Google Satellite,
OpenStreetMap, ...). `TileImageSource` exists because QGC's normal tile fetching is
buried inside a QtLocation plugin that can only feed a QtLocation `Map`; GeoMap
needs raw `QImage`s to upload as Quick 3D textures. It reuses the shared pieces
(cache database, provider/URL registry) with the same cache-first policy.

While a patch's own image is loading, `SurfacePatchModel` composites a stand-in from
whatever is available — the patch's four child tiles when present (sharper), else a
cropped piece of the nearest ancestor tile — so the ground never flashes blank.
Images from removed patches are retired rather than dropped, keeping them available
as stand-in sources across LOD changes.

## The surface: turning data into a mesh

`SurfaceModel` decides *which* patches exist. On every camera change it estimates
the visible ground region, walks the tile quadtree subdividing any patch that would
appear larger than ~384 px on screen, and diffs the result against the current set.
Changes are budgeted (a few adds/removals per pass, with follow-up passes
self-scheduled) because creating render delegates is synchronous and costly; a fast
zoom therefore streams detail in over several frames instead of hitching.

`SurfacePatchModel` is the QML bridge and the pipeline owner: it constructs the
height source, `HeightField`, `SurfaceModel`, and `TileImageSource` based on its
properties (`terrain`, `mapType`, `debugHills`), and exposes the patch set as a
`QAbstractListModel` — one row per patch, updated incrementally — that a
`Repeater3D` in `GeoMap.qml` turns into Quick 3D models.

Each patch renders as a `Model` combining:

- **`PatchGeometry`** — the grid mesh, displaced by the patch heights, with edge
  skirts and edge-vertex stitching so borders between different LOD levels never
  show cracks.
- **`PatchTextureData`** — the patch's tile image as a Quick 3D texture
  (`CheckerboardTextureData` is a procedural debug substitute).

`SurfaceAnalysis` is a diagnostic pass over the live patch set (coverage holes,
boundary seams, camera-below-surface), invoked via
`SurfacePatchModel::analyzeSurface()`. With the GeoMap debug UI on, `SurfaceModel`
also warns (`GeoMap.SurfaceModel.Cliffs`) each time an in-patch edge step steeper
than half a mesh cell appears, clears, or leaves with its patch. Steps near patch
corners are ignored: a corner resolved to other data leaves a small expected notch.

## Scene and camera

`GeoScene` defines the shared render space: scene coordinates are Web Mercator
meters relative to a movable `sceneOrigin`, which re-anchors to the camera whenever
it drifts more than 50 km so single-precision GPU floats stay accurate anywhere on
Earth. It also owns the two global scale factors — `verticalScale` (Mercator
latitude correction, so terrain isn't squashed away from the equator) and
`terrainScale` (the animated 0–1 factor behind the 2D↔3D transition). Everything
renderable positions itself through `GeoScene`, so a re-anchor shifts the whole
scene consistently.

`GeoMapCamera` is the camera pose model: center coordinate, heading, tilt, and
distance, with 2D/3D modes. Gestures mirror Google Earth: pan, orbit (right,
middle, or Shift+left drag), and zoom are cursor-anchored — the ground point
under the cursor at gesture start stays pinned — plus first-person look about a
fixed camera position (Ctrl+drag). Zoom levels match
QtLocation's for familiar behavior. Its outputs drive the `View3D`'s
`PerspectiveCamera`, and its projection math (`screenToGround`, `worldToScreen`)
underpins visibility estimation and overlay placement.

`GeoMap.qml` composes all of the above into the top-level map control: camera,
scene, patch model, `View3D`, gesture handlers, fog haze over the distant LOD ring,
and the 2D↔3D mode animation.

## Overlays: putting things on the map

`GeoMapItem` is the counterpart of QtLocation's `MapQuickItem`: it anchors QML
content at a geo-coordinate by projecting through the scene camera into the 2D layer
above the `View3D`. It supports clamp-to-ground or absolute altitude, and an
optional `delegate3D` — a Quick 3D node placed *inside* the scene that crossfades
with the 2D content during mode transitions. Built on it:

- **`GeoMapPin.qml`** — teardrop marker.
- **`GeoMapVehicleItem.qml`** — vehicle marker: 2D icon in map mode, a
  `PaperPlaneGeometry` dart with full attitude in 3D.
- **`GeoMapFlightPath.qml`** — the flown-trail ribbon (`FlightPathGeometry`, a
  shader-billboarded strip with constant on-screen width), flattening back to the
  classic 2D trail as `terrainScale` animates to 0.

Vehicle-related items apply a **home terrain bias** — the difference between the
elevation dataset's height at the home position and the vehicle's reported home
altitude — so a landed vehicle sits exactly on the rendered ground even when the
DEM and GPS altitudes disagree.

## Coordinate systems

| Space | Definition | Where conversion happens |
| --- | --- | --- |
| **Geodetic** | `QGeoCoordinate` (WGS84 lat/lon/alt AMSL) | `TileMath::geoToWorld` / `worldToGeo` |
| **World** | Web Mercator (EPSG:3857) meters; x east, y north, z up | `TileMath` only — the single conversion point |
| **Tile** | Slippy z/x/y (`TileMath::TileKey`), (0,0) NW | `TileMath::tileForWorld` / `tileMinCorner` |
| **Scene** | World minus `GeoScene::sceneOrigin`; z scaled by `verticalScale` (`terrainScale` is applied separately by render transforms) | `GeoScene::scenePositionFor` |
| **Patch-local** | Origin at patch center, ±span/2 | `PatchGeometry` |
| **Screen** | Pixels through the camera projection | `GeoMapCamera::worldToScreen` / `screenToGround` |

World-coordinate and projection math is double precision; elevation samples and
render-boundary data are stored as floats.

## Threading

Effectively single-threaded (GUI thread) by design. All asynchrony is event-loop
asynchrony: height and image sources always deliver results through queued
invocations, even when the answer is immediately available, so consumers see one
consistent flow. Real concurrency is delegated to existing infrastructure —
tile-cache lookups run on the tile engine's worker thread, network I/O on
`QNetworkAccessManager`, GPU upload on Qt Quick 3D's render thread.

---

### Overview (`tools/analyzers/README.md`)

# QGC Static Analyzers

Backends for [`tools/analyze.py`](../analyze.py) and QGC-specific static analyzers for C++ code.
For `analyze.py` usage and compiler-analysis prerequisites, see
[tools/README.md](../README.md#analyzepy); for CI planning, sharding, and result caching, see
[ci-overview.md](../../.github/ci-overview.md).

## Compiler Analysis Output

Compiler analysis (clang-tidy, Clazy) writes these files to the build directory:

- `<tool>-progress.jsonl` records each file's duration and status as it completes, so
  interrupted CI jobs retain partial timing evidence.
- `<tool>-timings.json` adds selection, elapsed and accumulated worker time, shard coverage,
  result-cache statistics, and (when profiling) per-check costs.
- `<tool>-diagnostics.json` lists each unique diagnostic once (line, column, severity, and check,
  by file relative to the checkout, or `build/...` for generated files), so reports can count a
  header's warnings once across translation units and shards. `commit` names the checkout whose
  line numbers they use (`GITHUB_SHA`, which on a pull request is GitHub's merge commit rather
  than the PR head, else `git rev-parse HEAD`; absent when neither is known). With `PR_HEAD_SHA`
  set (Code Analysis on a pull request), `changed` repeats those on lines the pull request changed
  (`common.git.changed_ranges` against the PR's diff base).
- `<tool>-raw.txt` keeps every translation unit's full output; the console shows each identical
  diagnostic block once.

`--cache-dir` (`result_cache.py`) replays results whose tool, configuration, compile command, and
preprocessed inputs are unchanged; tool failures, timeouts, and crashes always run again.

Per-check profiling adds substantial overhead and is disabled by default. Use
`python tools/analyze.py --tool clang-tidy --profile-checks src/LogManager/LogEntryTableModel.cc`
or the manual workflow's `profile_checks` input with a narrow path and `analyze_all: false`.
Profiled runs print the most expensive checks and archive the original profiles under
`clang-tidy-profiles/`. Check costs are summed across translation units and overlap with parallel
work. Ordinary runs retain per-file timing and progress without instrumenting individual checks.

## clang-tidy Fixes

`python tools/analyze.py --tool clang-tidy --fix [PATH...]` runs clang-tidy with
`--export-fixes` for each selected translation unit, writing `clang-tidy-fixes/*.yaml` in the build
directory, then merges them with `clang-apply-replacements --ignore-insert-conflict`. Headers shared
by several translation units therefore receive each fix once, which running `clang-tidy --fix` in
parallel cannot guarantee. The `clang-apply-replacements` beside `clang-tidy` is preferred (both ship
in LLVM packages and the pinned `clang-tidy` PyPI wheel), then `PATH`.

- Fix runs bypass `--cache-dir`: a replayed result would export no fixes.
- Header fixes are limited to this checkout's `src/`, `test/`, and `tools/`, because CPM sources and
  generated headers in other `src/` directories also match `.clang-tidy`'s `HeaderFilterRegex`.
- Fixes from translation units that failed to compile are discarded, as `clang-tidy --fix` does.
- Fixed code is not reformatted; run `just format-fix`, review with `git diff`, and rerun without
  `--fix` for findings that have no automatic fix.
- `analyze.py` refuses `--tool clang-tidy --fix` when `CI` or `GITHUB_ACTIONS` is set; CI only
  reports findings.

## clang-format Hook

`clang_format.py` also runs as the `clang-format` pre-commit hook
(`python3 tools/analyzers/clang_format.py [--fix] FILE...`). It checks, or with `--fix` formats, only
the lines that differ from the PR merge base (pre-commit's `PRE_COMMIT_FROM_REF` and
`PRE_COMMIT_TO_REF`) or, locally, from `HEAD`. New files are checked in full and deletion-only
changes are skipped, as in `analyze.py --tool clang-format`.

## Vehicle Null-Check Analyzer

Detects unsafe patterns where `activeVehicle()` results are used without null checks.

### Usage

```bash
# Analyze specific files
python3 tools/analyzers/vehicle_null_check.py src/Vehicle/*.cc

# Analyze directory recursively
python3 tools/analyzers/vehicle_null_check.py src/

# JSON output for CI/editor integration
python3 tools/analyzers/vehicle_null_check.py --json src/

# Show help
python3 tools/analyzers/vehicle_null_check.py --help
```

### Detected Patterns

| Pattern | Risk | Example |
| --- | --- | --- |
| `unsafe_active_vehicle_direct` | High | `activeVehicle()->method()` |
| `unsafe_active_vehicle_use` | High | Variable used after `activeVehicle()` without check |

### Example Output

```text
src/Example.cc:42:15: warning: unsafe_active_vehicle_direct
  activeVehicle()->parameterManager()->getParameter(...);
  Suggestion: Add null check before using activeVehicle():
  Vehicle *vehicle = MultiVehicleManager::instance()->activeVehicle();
  if (!vehicle) return;
```

### JSON Output Format

```json
[
  {
    "file": "src/Example.cc",
    "line": 42,
    "column": 15,
    "pattern": "unsafe_active_vehicle_direct",
    "code": "activeVehicle()->parameterManager()->...",
    "suggestion": "Add null check before using activeVehicle():..."
  }
]
```

### Pre-commit Integration

Already configured in `.pre-commit-config.yaml`. Runs automatically on C++ files.

```bash
# Run manually on all files
prek run vehicle-null-check --all-files
```

### Suppressing False Positives

The analyzer looks back 10 lines for null checks. If you have a valid null check that isn't detected, restructure your code to have the check closer to the usage, or add a comment explaining why it's safe.

```cpp
// Safe: null check is visible to analyzer
Vehicle *vehicle = MultiVehicleManager::instance()->activeVehicle();
if (!vehicle) {
    return;
}
vehicle->doSomething();  // OK - analyzer sees the check above
```

## QT_TRANSLATE_NOOP Check

`qt_translate_noop_check.py` reports `QT_TRANSLATE_NOOP`/`QT_TR_NOOP` results that are returned
or used as runtime strings (for example in `.arg()` or `QString(...)`). These macros only mark
strings for extraction; translate at the call site with `QCoreApplication::translate()` or `tr()`.
It runs as the `qt-translate-noop-check` pre-commit hook and, like the null-check analyzer, takes
files or directories and `--json`.

## qmlformat Check

`qmlformat.py` runs Qt's `qmlformat` (from `PATH`, like qmllint) on each file in stdout mode with the
nearest `.qmlformat.ini` and prints a unified diff where the output differs; it never rewrites files.
It backs `analyze.py --tool qmlformat` and the manual-stage `qmlformat` hook
(`prek run qmlformat --hook-stage manual --files <file.qml>`). Apply a result with `qmlformat -i`.

## Shared Utilities

`base.py` defines `AnalysisResult` and `AnalyzerBase` for `analyze.py` backends, and
`run_line_check_cli()`, the shared command line (paths and `--json`) of the two line-based checks.
File discovery uses `file_traversal.py` from [`tools/common/`](../common/README.md).

## Adding New Analyzers

1. Create a new Python script in `tools/analyzers/` and bootstrap imports as
   `vehicle_null_check.py` does (see [Bootstrapping Imports](../common/README.md#bootstrapping-imports))
2. Use `find_cpp_files()` from `common.file_traversal`
3. For an `analyze.py --tool`, subclass `analyzers.base.AnalyzerBase` and add one entry to
   `ANALYZERS` in `tools/analyze.py`
4. For a line-based check, fill `AnalysisResult.findings` and define
   `main(argv)` as `run_line_check_cli(YourAnalyzer, argv)`
5. Add tests under `tools/tests/`, and add to `.pre-commit-config.yaml` if appropriate, running it
   through `uv run --frozen --project tools --group scripts python` like the other hooks

---

### Overview (`tools/ci/README.md`)

# CI Scripts

`tools/ci/` contains the Python entrypoints that GitHub Actions workflows and composite actions run
by file path (`python3 "${GITHUB_WORKSPACE}/tools/ci/<package>/<script>.py"`). Each subpackage
groups one CI concern; put reusable helpers with multiple consumers in
[`tools/common/`](../common/README.md) or [`tools/qgc_tools/`](../qgc_tools/README.md).

## Packages

| Package | Scope |
| --- | --- |
| `planning/` | Change detection and build-matrix planners |
| `cache/` | Compiler, moc, CPM, and GitHub Actions cache planning and maintenance |
| `runner/` | Runner host preparation and managed runner images |
| `compile/` | CMake and Docker build drivers with timing and compiler-cache reporting |
| `packaging/` | Locate, validate, and boot-test packaged build outputs |
| `android/` | Android SDK setup, build retry, lint reporting, and emulator smoke tests |
| `ios/` | iOS packaging and simulator smoke tests |
| `publish/` | Attestation, publishing, release gating, draft release notes, and the GitHub Pages site |
| `reporting/` | Build-results comments, job-summary Setup and test sections, size and coverage reports, and PR labels |
| `gstreamer/` | GStreamer SDK packaging and mirroring |
| `security/` | Dependency SBOM generation and vulnerability-scanner installation |

Subpackage names avoid `build*` and `release`: `.gitignore` ignores directories with those names,
and Ruff skips `build` directories.

## Script Index

| Script | Purpose |
| --- | --- |
| `planning/analysis_matrix.py` | Plan Code Analysis jobs (configure and analyzer arguments per tool family): PR shards and scan scope, one cache-seeding job for master pushes, none when nothing compiled changed |
| `planning/android_matrix.py` | Library: the Android build legs (hosts, runners, ABIs) for `plan_platform.py` |
| `planning/detect_changes.py` | Library: decide which platforms an event builds, including the release-tag gate |
| `planning/plan_docker_builds.py` | Library: select the Docker variants an event builds, each with the image push target |
| `planning/plan_platform.py` | `_detect-changes.yml`'s planner: change detection and the release-tag gate once, then the Android or Docker matrix |
| `cache/build_cache_artifact.py` | Retain compiler/moc snapshots as short-lived workflow artifacts and restore them |
| `cache/cache_backend.py` | Detect whether RunsOn's S3 proxy serves the Actions cache and export `QGC_ACTIONS_CACHE_BACKEND` for `setup-python` |
| `cache/cache_plan.py` | Resolve the cache action's save policy, scope, keys, ccache budget, compiler/moc/CPM environment, and the sccache pilot's selection |
| `cache/ccache_helper.py` | Install the pinned, checksum-verified ccache (and mold, plus sccache for the [sccache pilot](../../.github/ci-overview.md#sccache-pilot), on Linux) and plan `save-build-cache`: gate PR saves, pack the snapshot artifact, and choose the cache keys |
| `cache/cpm_helper.py` | Fingerprint CPM dependencies and the GStreamer SDK config for `cache_plan.py`, and create and apply runner-image CPM seeds |
| `cache/gh_cache_cleanup.py` | List and optionally delete GitHub Actions caches, taking the branch and mode from `cache-cleanup.yml`'s triggering event (`CLEANUP_*` variables override them) |
| `runner/analysis_tools.py` | Install the clang-tidy wheel hash-pinned in `build-config.json`, verify `.clang-tidy`, and put LLVM, clang-tidy, and Clazy on `PATH` for `analysis.yml` |
| `runner/apt_install_retry.py` | Install apt packages with bounded retries, RunsOn's in-region EC2 mirror, and a runner-owned download cache |
| `runner/build_setup.py` | Validate `build-setup`'s inputs and resolve the values its steps read: Qt hosts, architectures and modules, cache target, Xcode and MSVC selections, Windows ARM64 cross builds' host Qt, Android ABIs and SDK environment, and the `build-config.json` tool versions |
| `runner/enable_kvm.py` | Open `/dev/kvm` to every user on a nested-virtualization runner |
| `runner/install_dependencies_helper.py` | `install-dependencies` entry points: plan the Linux apt cache key, install the apt packages or the macOS Homebrew packages and GStreamer (both without the tools CI pins elsewhere), and install Windows MSVC and NSIS (macOS and Windows in three timed attempts that kill a hung attempt's process tree) |
| `runner/install_multipass.py` | Install Multipass and wait for daemon readiness; with `--allow-bridge-forwarding`, undo Docker's iptables `FORWARD` drop policy so guests reach the network |
| `runner/runner_image.py` | Check `runner-images.yml`'s branch and AWS settings, then output, verify, promote, and retire QGC runner AMIs |
| `runner/validate_runner_configs.py` | `packer-validate` and `runs-on-lint` hooks: validate the runner image Packer template and lint the RunsOn configuration with the Packer and RunsOn CLI pinned in `build-config.json` |
| `compile/cmake_helper.py` | Run CI configure, build, and CTest steps with standard arguments and timing, tee configure and build output to logs, and write the job summary's Configure (result, duration, preset, generator, configure-check reuse) and Build (result, duration, toolchain, this build's compiler and moc cache hit rates, slowest build steps) sections; after a failure, annotate and summarize the first CMake error or the failed targets and first build errors |
| `compile/docker_helper.py` | Run Docker builds and repository scripts in builder images, and report compiler identities, timings, and cache statistics |
| `packaging/artifact_link.py` | Link an uploaded diagnostics artifact (upload-artifact's `artifact-url`) from the job summary |
| `packaging/find_artifact.py` | Find exactly one build artifact by glob pattern (fails on none or several), optionally copying it |
| `packaging/validate_native_package.py` | Validate native package identity, version, contents, and installed layout |
| `packaging/verify_executable.py` | Boot-test a built QGroundControl executable, AppImage, or DMG; keep a failed boot's output, stacks, crash reports, and minidumps for the diagnostics artifact |
| `android/android_boot_test.py` | Run the emulator boot smoke test |
| `android/android_build_retry.py` | Retry an Android build after a known intermittent Qt deployment-settings JSON truncation |
| `android/android_collect_diagnostics.py` | Collect emulator, ADB, AVD, and GStreamer diagnostics |
| `android/android_lint.py` | Run `gradlew lintRelease` in the Gradle project androiddeployqt generated, annotate the first Error and Fatal issues from lint's XML report at their `android/` sources (else Gradle's errors), and write the job summary's Android Lint section (result, duration, error and warning counts, first errors); exits with Gradle's status |
| `android/android_sdk_helper.py` | Install the `build-config.json` Android SDK/NDK packages with retries, configure tool paths, and create the PR debug keystore |
| `ios/ios_boot_test.py` | Run the QGC smoke test in a disposable iOS simulator within a 25-minute budget; save each failed attempt's simulator and CoreSimulator logs and crash reports, annotate the failing phase and attempt, and write the Boot test summary |
| `ios/ios_package.py` | Select signing profiles and validate/package iOS bundles |
| `publish/attest_helper.py` | Prepare artifacts for attestation (checksum sidecar, CPM dependency SBOM, build manifest) and decide the AWS publish; verify or write standalone checksums |
| `publish/aws_upload.py` | Publish artifacts to S3 `builds/<ref>/` (the runner's ref by default), plus `latest/` and one CloudFront invalidation for release tags |
| `publish/deploy_docs.py` | Deploy generated documentation to an external Pages repository |
| `publish/pages_history.py` | Library: the Pages dashboard's history of master push builds: fetch the deployed `data/history.json` (any failure but a 404 stops the build, and so does a 404 once a `pages.yml` run has succeeded), collect each new run's result, wall time, Build-step durations, package sizes, JUnit suites and failed tests, `src/` line coverage, and unique Code Analysis warnings, newest first until the 15-minute collection deadline, with two 120-second attempts per artifact, and merge by commit and workflow within the 1,000-commit and 365-day caps |
| `publish/pages_render.py` | Library: the Pages site's self-contained HTML pages and inline SVG charts (no scripts or external assets), each chart followed by a table of its latest values and linking every run |
| `publish/pages_site.py` | Build the GitHub Pages site for `pages.yml`: the CI health dashboard, `schemas/` (each `$id` must be its site URL), `downloads/` of the S3 master packages with their SHA-256 sidecars and provenance commits, and `api/` from the latest master Doxygen artifact or a link to api.qgroundcontrol.com (also when its download stalls); `--new-history` (`PAGES_NEW_HISTORY`) starts a new history when the deployed one is gone; checks its pages' internal links and writes the job summary's Pages site section |
| `publish/release_branch_check.py` | Stop `release.yml` before it drafts a release for a tag that no `origin/Stable*` branch contains |
| `publish/release_notes.py` | Draft a tag's release notes with git-cliff (`tools/configs/cliff.toml`) and create its draft GitHub Release unless one exists |
| `reporting/collect_build_status.py` | Collect platform, pre-commit, Code Analysis, and every other workflow's status for a head SHA (with `--status-out`, each run's state, attempt, duration and total wall time, and failed-step links and lost runners from its jobs) and save the latest runs for `download_artifacts.py` |
| `reporting/download_artifacts.py` | Download prefix-selected artifacts from matching completed workflow runs into one directory each, with a manifest naming each artifact's workflow and whether it downloaded, and record each workflow's latest run and its distributable artifacts (size, id, expiry) |
| `reporting/generate_build_results_comment.py` | Render the consolidated PR build-results comment: verdict, per-workflow table, other checks, the PR base's builds, failures, and collapsed tests, coverage, artifact downloads, pre-commit, and Code Analysis details, compared with the base's own builds |
| `reporting/gh_pr_size_label.py` | Apply a pull request's `size/*` label from its changed lines |
| `reporting/junit_report.py` | Summarize JUnit results (counts, failures, slowest tests) in the job summary, annotate failed tests and, optionally, slow tests (parsed with `common/junit.py`, like the build-results comment and the Pages dashboard) |
| `reporting/report_context.py` | Resolve a pull_request build's PR and base commit, and with `--comment` recheck that the build is current and create or update the PR's build-results comment |
| `reporting/setup_summary.py` | Write the job summary's Setup section for `build-setup`, also after a failed setup step, which it names: runner, image, RunsOn instance, CPUs and RAM, Qt (version, installer, source), CMake, compiler, Xcode, JDK, Android NDK, Python groups, and each build cache's restore result |
| `reporting/size_analysis.py` | Analyze and report binary-size changes |
| `reporting/verify_coverage_thresholds.py` | Enforce line and branch coverage thresholds (read from `CMakeCache.txt` with `--build-dir`) |
| `reporting/workflow_runs.py` | Library: read the triggering `workflow_run` payload and the configured platform workflows, which default the reporting scripts' run-query arguments; validate, select, and group workflow runs; load run snapshots |
| `gstreamer/gstreamer_archive.py` | Package GStreamer builds and optionally upload them to S3 |
| `gstreamer/mirror_gstreamer.py` | Verify and mirror upstream GStreamer release artifacts to S3 (`mirror-gstreamer.yml` sets its options through `MIRROR_*` variables) |
| `security/generate_cpm_sbom.py` | Generate a CycloneDX or SPDX SBOM from CPM package metadata and the bundled GStreamer |
| `security/install_grype.py` | Install checksum-verified Grype release assets into the scan action tool cache |

The script index and one-to-one production test coverage are checked by
[`tools/tests/ci/contracts/test_scripts_docs.py`](../tests/ci/contracts/test_scripts_docs.py).

## Shared Code and Bootstrapping

Entrypoints bootstrap like `tools/setup/` scripts: insert the `tools/` directory into `sys.path`
with one bare statement, import `_bootstrap` for the CI debug hooks, then import from the module
that owns the symbol ([details](../common/README.md#bootstrapping-imports)). Scripts import each
other as package modules:

```python
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

import _bootstrap as _bootstrap
from common.gh_actions import write_github_output
from qgc_tools.release_tag_check import check_release_tag

from ci.planning.detect_changes import detect_builds
```

`ci` is the top import-linter layer: it may import the other `tools/` packages, and none of them may
import it. Its subpackages are independent of each other (only `publish/attest_helper.py` reuses
`security/generate_cpm_sbom.py`); move code that several of them need into `common` or `qgc_tools`.
Keep package `__init__.py` files import-free. Sparse-checkout jobs must include the
entrypoint, `tools/_bootstrap.py`, imported `ci` modules with their package `__init__.py` files, and
transitive `common` and `qgc_tools` modules; `test_bootstrap_sparse_checkout.py` verifies that
closure.

`cache/cache_backend.py`, `runner/apt_install_retry.py`, `runner/enable_kvm.py`,
`runner/install_multipass.py`, `runner/runner_image.py`, `packaging/validate_native_package.py`,
and `ios/ios_boot_test.py` are standalone: they import only the standard library and skip the
bootstrap. `runner-images.yml` checks out `runner_image.py` by itself, so it must stay that way.

Scripts that run before `setup-python` use the runner's system interpreter and must import only the
standard library and stdlib-only `tools/` modules. `PRE_SETUP_PYTHON_SCRIPTS` in
[`tools/tests/test_stdlib_only.py`](../tests/test_stdlib_only.py) lists them; the test imports each
one under `python -I -S` and fails when a job without `setup-python` runs a script missing from the
list. `setup-python` puts `tools/.venv` first on `PATH`; on Windows `install_python.py` also copies
the venv's `python.exe` to `python3.exe`, so later `python3` steps run in the venv on every OS.
As a fallback for a runner whose `PATH` still prefers its own interpreter, the test also holds every
script that a composite action runs as `python3` to the same rule; `_VENV_ACTION_SCRIPTS` exempts
the few that need third-party packages.

## Tests

Add `tools/tests/ci/test_<script>.py` with every new production script; every test there must match
a script. Entry scripts define `main(argv: list[str] | None = None) -> int`
(`tools/tests/test_bootstrap.py` checks the signature), so tests call `main([...])` instead of
patching `sys.argv`. Tests of workflows, composite actions, presets, and deploy files go in
`tools/tests/ci/contracts/`. Load repository YAML through `tools/tests/_helpers.py`
(`load_workflow()`, `load_action()`, `load_yaml()`, `get_steps()`, `get_step()`): it parses each
file once with libyaml, keys workflow triggers under `"on"`, and returns a private copy;
`workflow_names()` and `action_names()` enumerate them. Build temporary git repositories with the
`git_repo` fixture, `_helpers.git()` and `commit_files()`, which ignore user and system git
configuration. Point `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH` and `GITHUB_STEP_SUMMARY` at
empty temporary files with the `gh_output`, `gh_env`, `gh_path` and `gh_summary` fixtures, and
parse what a script wrote with `read_github_file()`, which also reads `name<<DELIMITER` blocks.
`workflow_run_event(**fields)` points `GITHUB_EVENT_PATH` at a `workflow_run` payload.
Import scripts outside the `tools/` packages with `load_script()`. A test reads the repository files
it checks directly, without skipping when they are missing: a file absent from a CI sparse checkout
fails the test. Mark the tests of each script that `windows.yml` runs on Windows, and of other
Windows-specific code paths, with `pytestmark = pytest.mark.windows`; the Windows CI job runs
`pytest -n 2 --durations=15 -m windows tools/tests` (two xdist workers are a trial; revert to
serial if CI shows it slower).
Run the same suite as CI (`pytest -q -n auto tools/tests` in the locked `tools/.venv`):

```bash
just test-python
```

Tests run in parallel (pytest-xdist) and in random order (pytest-randomly, which also reseeds
`random`), so a test must not depend on another test's side effects. The run ends with
`Reproduce the test order with --randomly-seed=N`; replay that order serially with
`just test-python -n 0 --randomly-seed=N`, or run in file order with `-p no:randomly`.
`tools/tests/conftest.py` restores `os.environ` after each test, and for the whole session removes
the runner's context (`CI`, `GITHUB_*`, `RUNNER_*`, `ACTIONS_*`, `RUNS_ON_*`, `QGC_CI_DEBUG`,
`QGC_ACTIONS_CACHE_BACKEND`), so tests behave the same in CI as locally and never write to the
job's real output or summary files; a test that needs one of these variables sets it.
pytest-timeout fails any test that runs longer than 60 s. The `test` group also provides:

- pytest-subprocess's `fp` fixture for sequences of faked commands (`fp.register()`); prefer it
  over patching `subprocess.run` when the code under test runs real commands.
- Hypothesis for property tests (`@given`); `conftest.py` disables its per-example deadline.
  Hypothesis keeps failing examples in `.cache/hypothesis/` (set in `conftest.py`) and replays them
  first on the next run.
- syrupy's `snapshot` fixture for rendered output; snapshots live in `__snapshots__/*.ambr` next
  to the test. After an intended change, refresh them with `--snapshot-update` and review the diff.

---

### Overview (`tools/coding-style/README.md`)

# QGroundControl Coding Style Examples

Reference files demonstrating QGroundControl's coding conventions. For the authoritative rules
(naming, include order, C++20, QML, logging), see **[CODING_STYLE.md](../../CODING_STYLE.md)** — these
files are worked examples of it.

## Files

| File | Demonstrates |
| --- | --- |
| `CodingStyle.h` | Header conventions: `#pragma once`, include order, forward declarations, `Q_DECLARE_LOGGING_CATEGORY`, QML macros, naming |
| `CodingStyle.cc` | Implementation conventions: include order, `QGC_LOGGING_CATEGORY`, C++20 features, const correctness |
| `CodingStyle.qml` | QML conventions: import/property order, naming, declarative bindings |

## Related Documentation

- **[CODING_STYLE.md](../../CODING_STYLE.md)** - Full coding style guide
- **[.clang-format](../../.clang-format)** - Automatic C++ formatting rules
- **[.clang-tidy](../../.clang-tidy)** - Static analysis configuration

## Formatting & Static Analysis

```bash
./tools/analyze.py --tool clang-format --fix        # Format lines changed since the default branch
./tools/analyze.py --tool clang-format              # Check changed lines (same rule as the pre-commit hook)
./tools/analyze.py --tool clang-format --fix --all  # Reformat whole files (dedicated formatting change only)
./tools/analyze.py                                   # clang-tidy on changed files
./tools/analyze.py --all                             # clang-tidy on all files
```

Or use the `just` wrappers (`just format-fix`, `just analyze`) — see [tools/README.md](../README.md).

---

### Overview (`tools/common/README.md`)

# QGC Shared Tooling

`tools/common/` contains small, dependency-light helpers shared by QGC developer and CI scripts.
Import from the module that defines a helper; `common/__init__.py` intentionally does not re-export
symbols. Explicit imports keep runtime and sparse-checkout dependencies visible.

```python
from common.file_traversal import find_repo_root
from common.proc import run_captured
```

## Module Index

| Module | Purpose |
| ------ | ------- |
| `analysis_diagnostics.py` | analyze.py's `*-diagnostics.json` reports: each tool's unique warnings across shards, and those on changed lines |
| `aws.py` | Allowlisted public S3 object checks and uploads |
| `build_config.py` | `.github/build-config.json` lookup, validation, and CI export |
| `cmake.py` | CMake cache variable parsing |
| `coverage_summary.py` | gcovr JSON summary line, function, and branch coverage metrics |
| `file_traversal.py` | Repository-root discovery and filtered C++ file traversal |
| `format.py` | Human-readable byte, size-delta, count, duration, percentage, and ratio formatting, and one-line clipped names (`clip`) |
| `gh_actions.py` | GitHub CLI calls, read-only API retries and JSON reads, workflow artifact archive downloads, GitHub timestamps and durations, annotations (with optional `title`, `file`, `line`, and `col`), log groups, outputs, summaries, and CI detection |
| `git.py` | Captured Git commands, default-branch discovery, pull request diff refs, diff bases, and changed line ranges |
| `gstreamer.py` | GStreamer SDK package and mirror URLs and their pinned checksums |
| `io.py` | JSON/TOML I/O, checksums, atomic writes, and safe archive extraction |
| `junit.py` | CTest JUnit XML test cases (outcome, duration, failure excerpt) and suite wall time |
| `logging.py` | Color-aware terminal logging |
| `markdown.py` | GitHub-Flavored Markdown tables, code spans and blocks, `<details>` blocks, untrusted-text escaping, and the shared job-summary section layout (`summary_section`) |
| `net.py` | Dependency-free atomic downloads and small fetches with optional SHA-256 verification; retries only transient failures (connection errors, timeouts, 5xx, 408, 429), so a 404 fails at once; `fetch_release_member` extracts one member from a pinned release archive |
| `paths.py` | `TOOLS_DIR` and `REPO_ROOT`, resolved from the module location |
| `platform.py` | OS and CPU architecture normalization |
| `proc.py` | Captured, tee (through Bash, or a live-flushed Python pipe), and bounded-retry subprocess execution (optionally killing a timed-out attempt's whole process tree, `kill_process_tree`; the final failure logs the command and the end of its captured stderr), bytes-or-text output decoding, `require_tools` (`MissingToolError` names every tool missing from `PATH`), and deadline polling with `wait_until` |
| `qt.py` | Qt installer-kit discovery for host tools such as `qt-cmake` and `lupdate` |
| `tool_version.py` | External-tool and `uv.lock` version lookup |
| `xml.py` | Safe XML parsing with entity-declaration rejection |

API behavior and edge cases are covered by matching files under `tools/tests/`, such as
`test_proc.py`, `test_io.py`, and `test_gh_actions.py`.

## Bootstrapping Imports

Top-level `tools/*.py` scripts run with `tools/` as `sys.path[0]` and import `common` directly.
Entry scripts in subdirectories (`tools/setup/`, `tools/ci/<package>/`, ...) first insert `tools/`
with one bare statement, which Ruff's import-position rule (E402) accepts:

```python
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))  # tools/ci/<package>/x.py

import _bootstrap as _bootstrap
from common.proc import run_captured
```

`import _bootstrap as _bootstrap` installs the CI debug hooks (see
[`tools/README.md`](../README.md#python-script-conventions)); the redundant alias marks the unused
import as intentional. Library modules without a `__main__` block do not touch `sys.path`.
`tools/tests/test_bootstrap.py` enforces this shape.

CI jobs that use sparse checkout must include the entrypoint, `tools/_bootstrap.py`, and transitive
`ci`, `common`, and `qgc_tools` modules. `test_bootstrap_sparse_checkout.py` checks that closure
automatically.

## Adding or Reusing Helpers

Before adding a helper, search this directory and its call sites. Add shared code only when it has
multiple consumers or centralizes a correctness boundary such as safe extraction, retries, GitHub
output encoding, or platform normalization.

When a new helper is warranted:

1. Put it in the narrowest existing module; create a module only for a distinct concern.
2. Keep imports dependency-light and defer optional packages until the function that needs them.
3. Export the supported surface through the module's `__all__` when it has one.
4. Add focused tests under `tools/tests/`.
5. Run `just test-python` and `just lint` (Ruff, Pyright, and import-linter run as pre-commit hooks).
6. Update sparse-checkout lists if the automatic policy test reports a missing module.

---

### Overview (`tools/qgc_tools/README.md`)

# QGC tooling domains

Reusable repository policy lives here; CLI and platform entrypoints call it.

- `build_artifacts.py`: which GitHub artifacts are distributable packages (the build-results
  comment's and the Pages dashboard's sizes), and the packages S3 publishes to `builds/<ref>/`
  (the Pages downloads page).
- `build_profile.py`: Ninja log and Clang time-trace hotspot summaries, CI build-timing snapshots,
  and their job-summary tables.
- `build_errors.py`: the errors that explain a failed configure or build in its output (CMake
  errors, Ninja and Xcode failed targets, GCC/Clang/javac/MSVC compiler and linker errors, Gradle
  failures) and their checkout-relative paths, shared by `cmake_helper.py` and `android_lint.py`.
- `compiler_cache.py`: ccache and sccache statistics for build summaries and cache-save gates.
- `cmake_configure.py`: configure-preset selection and `qt-cmake` invocation for `configure.py` and CI.
- `python_env.py`: locked dependency profiles, environment and executable resolution.
- `docker_variants.py`: Docker variant manifest and validation shared by CI and deployment, and
  the builder image's registry push target for an event.
- `release_tag_check.py`: whether a tag is a `vX.Y.Z` release tag reachable from a `Stable*` branch,
  shared by change detection, build planning, and publishing.

This package may import `common`, but not `setup`, `analyzers`, `generators`, `terrain`, or
`translations`.
`common` must not import this package or any command entrypoint. Run
`cd tools && lint-imports` from the developer environment
when changing these boundaries.

---

### Overview (`tools/simulation/README.md`)

# QGC Simulation Tools

Tools for testing QGroundControl without physical hardware. Run them from the repository root.

## Quick Start

| Tool | Use Case | Setup |
| --- | --- | --- |
| `mock_vehicle.py` | UI testing, quick checks | `python3 tools/setup/install_python.py simulation` (pymavlink), then activate `tools/.venv` |
| `run_arducopter_sitl.py` | Full simulation, mission testing | Docker, or an ArduPilot SITL build with `--native` |
| `fly_plan.py` | Fly a QGC `.plan` in ArduPilot or PX4 SITL and record the track and images | `python3 tools/setup/install_python.py simulation` (pymavlink), then activate `tools/.venv` |
| `rc_transmitter.py` | RC transmitter for PX4 SITL: flight modes, switches, RC loss | `python3 tools/setup/install_python.py simulation` (pymavlink), then activate `tools/.venv` |
| `mock_ntrip_caster.py` | NTRIP/RTK client testing | Python standard library only |

## Mock Vehicle (Lightweight)

A minimal MAVLink simulator for UI testing. Does not simulate flight dynamics.

```bash
# Run (QGC connects to UDP 14550)
python tools/simulation/mock_vehicle.py

# Multiple vehicles
python tools/simulation/mock_vehicle.py --system-id 1 --port 14550 &
python tools/simulation/mock_vehicle.py --system-id 2 --port 14551 &

# TCP mode (SITL-style): listens for QGC's TCP comm link
python tools/simulation/mock_vehicle.py --tcp --port 5760
```

**Features:**

- Heartbeat, GPS, attitude, battery telemetry
- Arm/disarm commands
- Mode changes
- Basic parameter support

**Options:**

| Option | Default | Description |
| --- | --- | --- |
| `--host` | 127.0.0.1 | Host address |
| `--port` | 14550 | Port number |
| `--tcp` | off | Listen on TCP instead of sending UDP |
| `--system-id` | 1 | MAVLink system ID |
| `--rate` | 10 | Telemetry rate (Hz) |
| `--sitl` | off | SITL-compatible mode (listen on TCP 5760) |
| `--lat`, `--lon`, `--alt` | `MOCK_VEHICLE_LAT`/`_LON`/`_ALT`, else 47.3977, 8.5456, 100 m | Initial position |

**Limitations:** No flight dynamics, no mission execution, limited MAVLink support.

Mock Vehicle auto-connects over UDP 14550. In TCP mode, add a TCP comm link in QGC to the host and
port it listens on.

## ArduCopter SITL (Full Simulation)

Full ArduPilot simulation via Docker, or a [local build](#without-docker). Supports missions,
geofences, all commands.

```bash
# Run (builds image on first run, ~10-15 min)
python tools/simulation/run_arducopter_sitl.py

# With simulated network latency (Herelink-like)
python tools/simulation/run_arducopter_sitl.py --with-latency

# Connect QGC to: tcp://localhost:5760
```

`--name` sets the container name (default `arducopter-sitl`) and `--timeout` the seconds to wait
for the TCP listener (default 60). The launcher binds TCP port 5760 to localhost and waits for the
guest listener without consuming its first client connection. It replaces only containers carrying
its ownership label; remove a container created by the previous shell launcher explicitly before
using the same name.

The image is built from the third-party `radarku/ardupilot-sitl-docker` Dockerfile, pinned to a
reviewed commit (`BUILD_CONTEXT`). An existing `ardupilot-sitl-4.5.6` image is reused; remove it
with `docker rmi ardupilot-sitl-4.5.6` to rebuild after the pin changes.

Add a TCP comm link in QGC (**Application Settings → Comm Links → Add**, Host `localhost`,
Port `5760`).

**Docker Commands:**

```bash
docker logs -f arducopter-sitl   # View logs
docker stop arducopter-sitl      # Stop simulation
docker rm arducopter-sitl        # Remove container
```

### Without Docker

`--native` runs the SITL build in an ArduPilot checkout with the same vehicle, home position, and
parameters as the Docker image.

```bash
# Build ArduCopter SITL once
git clone --recurse-submodules https://github.com/ArduPilot/ardupilot.git ~/ardupilot
cd ~/ardupilot
Tools/environment_install/install-prereqs-ubuntu.sh -y
./waf configure --board sitl
./waf copter

# Run from the QGC repository root; Ctrl+C stops it
python tools/simulation/run_arducopter_sitl.py --native ~/ardupilot
```

Without root access, skip `install-prereqs-ubuntu.sh` and install the build's Python packages into
a virtual environment instead: `empy==3.3.4 future lxml pexpect ptyprocess dronecan`.

ArduPilot SITL exits when the process that started it does, so the launcher stays in the
foreground. Each run starts in a new temporary directory, which holds the log and the vehicle's
parameters. SITL listens on all interfaces: QGC connects on TCP 5760, and TCP 5762 and 5763 are
spare MAVLink ports for other tools such as `fly_plan.py`. `--with-latency` needs Docker.

## PX4 SITL (SIH)

PX4's simulation-in-hardware (SIH) model runs PX4 SITL with its own physics, without Gazebo.

```bash
git clone --recursive https://github.com/PX4/PX4-Autopilot.git ~/PX4-Autopilot
cd ~/PX4-Autopilot
make px4_sitl sihsim_quadx
```

QGC connects automatically over UDP 14550. Other tools, such as `fly_plan.py` and
`rc_transmitter.py`, use the offboard link on UDP 14540. Set `PX4_SIM_SPEED_FACTOR` to run faster
than real time, and `PX4_HOME_LAT`, `PX4_HOME_LON`, and `PX4_HOME_ALT` to start somewhere else,
such as a plan's planned home position:

```bash
PX4_SIM_SPEED_FACTOR=5 PX4_HOME_LAT=47.6335 PX4_HOME_LON=-122.0898 PX4_HOME_ALT=0 \
    make px4_sitl sihsim_quadx
```

## Fly a Plan

`fly_plan.py` checks mission generation against a real autopilot without driving the QGC user
interface. It uploads a saved `.plan`, flies it in AUTO, and records the flown track and the
position of every image the autopilot reports.

```bash
# ArduPilot SITL: a spare serial port, so QGC can stay connected on its own link
./fly_plan.py survey.plan --connect tcp:127.0.0.1:5762 --svg flight.svg

# PX4 SITL: the offboard link
./fly_plan.py survey.plan --connect udpin:0.0.0.0:14540 --svg flight.svg
```

The plan's simple items and the mission items QGC saves for surveys and corridor scans are uploaded
as QGC would upload them. Structure scans and landing patterns do not save their mission items, so
upload those plans from QGC. ArduPilot takes off in GUIDED to `--takeoff-alt` before switching to
AUTO, since it does not arm in AUTO by default; PX4 starts the mission on the ground. Afterwards the
vehicle returns to launch unless `--no-rtl` is given.

The JSON output has the uploaded mission, the track (position, altitude, heading and current
mission item), the images, and the vehicle's status messages. `--svg` plots the planned path
(dashed), the flown track, and the images. The script exits with 1 if the mission does not
complete within `--timeout`.

Images are only reported with a camera configured:

- ArduPilot: `CAM1_TYPE 1`, then reboot. Images are reported as `CAMERA_FEEDBACK`.
- PX4: `TRIG_MODE 4` and `TRIG_INTERFACE 3`, then restart or run `camera_trigger start`. Images
  are reported as `CAMERA_IMAGE_CAPTURED`.

| Option | Default | Description |
| --- | --- | --- |
| `--connect` | required | pymavlink connection to the vehicle |
| `--output` | `flight.json` | JSON output |
| `--svg` | off | Also plot the planned and flown paths |
| `--takeoff-alt` | 10 | ArduPilot takeoff altitude before AUTO (m) |
| `--timeout` | 1800 | Seconds to wait for the mission to complete |
| `--no-rtl` | off | Do not return to launch afterwards |

## Mock NTRIP Caster

Serves an NTRIP source table and streams RTCM3 1005/1077 frames with valid CRC-24Q, for testing
QGC's RTK corrections client without a real caster.

```bash
python tools/simulation/mock_ntrip_caster.py                 # Listen on 127.0.0.1:2101
python tools/simulation/mock_ntrip_caster.py --host 0.0.0.0  # Accept LAN connections
```

In QGC, open **Application Settings → RTK Corrections → NTRIP Server**, set host `127.0.0.1`, port
`2101`, and mount point `MOCK1`, then enable **Connect to NTRIP server**.

`MOCK1` and `MOCK2` stream data. `MOCK_AUTH` is listed as requiring Basic auth, but authentication
is only enforced with `--auth USER:PASS`, and then for every request. Unknown mount points return
404.

| Option | Default | Description |
| --- | --- | --- |
| `--host` | 127.0.0.1 | Bind address |
| `--port` | 2101 | Port number |
| `--auth` | off | Require Basic auth (`USER:PASS`) |
| `--rate` | 1 | Messages per second |
| `--error` | off | `bad_crc` corrupts every 10th frame, `drop` closes after 10 frames, `http_401`/`http_500` reject every request |

## Virtual RC Transmitter (PX4 SITL)

Simulates an RC transmitter for PX4 SITL, which has no RC receiver. A web page has a slider and
1000/1500/2000 buttons for each of the 18 channels. The script streams the PWM values as
`RC_CHANNELS_OVERRIDE`, which PX4 treats as receiver input, so mode channels, mode buttons and
switches behave as with a real transmitter. QGC stays connected on its own link at the same time.

```bash
# SITL on this machine: uses the PX4 offboard link (UDP 14540), not QGC's 14550
./rc_transmitter.py

# SITL in a VM or on another machine: connect to its offboard link port
./rc_transmitter.py --connect udpout:10.211.55.7:14580

# Then open http://localhost:8080
```

PX4 SITL only reads MAVLink joystick input by default. Set it to use RC, and map the sticks so
the mode channel and switches can change modes:

```text
param set COM_RC_IN_MODE 0
param set RC_CHAN_CNT 8
param set RC_MAP_ROLL 1
param set RC_MAP_PITCH 2
param set RC_MAP_THROTTLE 3
param set RC_MAP_YAW 4
```

**Stopped** pauses sending, to test the RC loss failsafe.

**Options:**

| Option | Default | Description |
| --- | --- | --- |
| `--connect` | `udpin:0.0.0.0:14540` | pymavlink connection to the vehicle |
| `--port` | 8080 | Web page port |
| `--rate` | 20 | Send rate (Hz). PX4 treats RC as lost after `COM_RC_LOSS_T` (0.5 s) without it |

## Comparison

| Feature | mock_vehicle.py | ArduCopter SITL |
| --- | --- | --- |
| Setup time | Seconds | 10-15 min (first run) |
| Dependencies | pymavlink | Docker or an ArduPilot build |
| Flight dynamics | ❌ | ✓ |
| Mission execution | ❌ | ✓ |
| Geofence support | ❌ | ✓ |
| All MAVLink commands | ❌ | ✓ |
| Resource usage | Low | Medium |
| Best for | UI testing | Integration testing |

## Other Simulators

For more advanced simulation:

- [ArduPilot SITL](https://ardupilot.org/dev/docs/sitl-simulator-software-in-the-loop.html) - Native install
- [PX4 SITL](https://docs.px4.io/main/en/simulation/) - PX4 simulation
- [Gazebo](https://gazebosim.org/) - 3D physics simulation

---

### Overview (`tools/terrain/README.md`)

# Terrain Tiles

`tools/terrain/` builds QGC's elevation tiles, served at
`https://terrain.assets.qgroundcontrol.com/terrarium/{z}/{x}/{y}.png`
([#15090](https://github.com/mavlink/qgroundcontrol/issues/15090)). The entry point is
[`../terrain_tiles.py`](../terrain_tiles.py).

The tiles serve two consumers, and the format decisions below are driven by the second:

- **GeoMap display:** the 3D terrain surface, through `TerrariumTileFetcher`.
- **Vehicle terrain (planned):** `TerrainProtocolHandler` answers ArduPilot's `TERRAIN_REQUEST`
  with `TERRAIN_DATA` sampled from these tiles, replacing the Auterion source
  ([#15252](https://github.com/mavlink/qgroundcontrol/issues/15252)). The vehicle also caches
  that data on its SD card, so this is the only way these tiles reach a vehicle. QGC never writes
  ArduPilot `.DAT` files.

## Output Format

| Property | Value |
| -------- | ----- |
| Projection | Web Mercator (EPSG:3857) slippy tiles, `y` increasing southward |
| Zooms | z0–z13; a z13 pixel is 19 m at the equator, 13.5 m at 45° |
| Tile | 256×256 RGB PNG, terrarium encoding: `height = R*256 + G + B/256 - 32768` m |
| Pixel convention | Pixel `(px, py)` covers `[px, px+1) / 256` of its tile; its value is the height at the pixel center, matching `TerrariumTileFetcher` |
| Height step | 0.25 m: heights are rounded to the nearest quarter meter |
| Heights | Copernicus DSM (surface incl. canopy, buildings, snow and ice) in meters above the EGM2008 geoid |
| Ocean | Exactly 0 m; all-ocean tiles share one byte-identical 564-byte PNG |
| Metadata | `tilejson.json` (TileJSON 3.0.0 with the data `version` and an update `notice`, plus `encoding`, `height_step_m`, `vertical_datum`, `surface`) and `ATTRIBUTION.txt`, next to `terrarium/` |

The tiles decode with today's terrarium client, but switching QGC to them takes more than a new
URL; see [Required QGC Client Changes](#required-qgc-client-changes).

## Required QGC Client Changes

None of these exist yet: QGC still reads Tilezen (`TerrariumElevationProvider::kProviderURL` in
`src/QtLocationPlugin/Providers/ElevationMapProvider.h`). They must ship together with the URL
switch, otherwise fixes published here never reach users.

1. **URL and zoom.** Point the provider at
   `https://terrain.assets.qgroundcontrol.com/terrarium/{z}/{x}/{y}.png` and lower
   `TerrariumTileFetcher::kMaxTileZoom` from 15 to 13. The fetcher already derives deeper
   zooms from their z13 ancestor.
2. **A cache identity of its own.** QGC's tile cache keys tiles by provider type and x/y/z
   (`UrlFactory::getTileHash`), ignores HTTP cache headers, and never expires a tile; only the
   cache size limit evicts them. Reusing the `Terrarium` provider key would mix users' cached
   Tilezen tiles with these. Give the new source its own provider key, or clear the old one's
   tiles on upgrade.
3. **Follow the data version, and tell the user.** Tiles are updated in place when a generator
   bug is fixed (see [Publishing and Updates](#publishing-and-updates)), and the tile cache
   would otherwise keep the old ones indefinitely.
   - Fetch `tilejson.json` at startup and again periodically while running (it is cached for
     five minutes), so long sessions see an update too.
   - If its `version` differs from the version last stored in settings, delete this provider's
     cached tiles, then store the new version. Store it only after the cache is cleared. On the
     first run (no stored version) just store it.
   - Never discard tiles silently: show the `notice` text (`{"severity", "text"}`). An `info`
     notice can be a non-blocking message; a `critical` one must stay until the user
     acknowledges it, because data that already left the cache (saved plans with terrain
     altitudes, missions on a vehicle, vehicle SD card terrain) is not fixed by re-downloading.
     If a newer version arrives without a notice, still say that terrain data was updated.
   - If the fetch fails, keep the cache and the stored version. Users flying offline keep the
     old tiles until QGC next starts online.
4. **403 is a failure, not ocean.** Every key exists in a full upload, and missing keys return
   403 (not 404) because CloudFront cannot list the bucket. A 403 must not be cached or decoded
   as sea level.
5. **Attribution.** Show the Copernicus notice (`tilejson.json` `attribution`, or
   `ATTRIBUTION.txt`) wherever this terrain is surfaced, replacing the Tilezen attribution.
6. **Vertical datum.** Heights are above EGM2008 (`vertical_datum` in `tilejson.json`). Anything
   that compares them with vehicle or GPS altitudes must use the same datum.
7. **Vehicle terrain (planned, [#15252](https://github.com/mavlink/qgroundcontrol/issues/15252)).**
   If `TerrainProtocolHandler` answers `TERRAIN_REQUEST` from these tiles:
   - Round heights to the nearest meter when converting to `int16`; truncating reports ground
     up to 1 m low, the unsafe direction.
   - Unit-test what is sent the way the vehicle samples it, as terraingen's `slice_graph.py`
     does for its files: grid point positions against `AP_Terrain`'s single flat-earth offset
     (QGC chains geodesic steps today), and rounded heights on a synthetic linear ramp. The
     generator's validation checks the tiles, not the points QGC samples from them. Details in
     [#15252](https://github.com/mavlink/qgroundcontrol/issues/15252#issuecomment-6255256391).
   - The vehicle caches `TERRAIN_DATA` on its SD card, stamping each block with the firmware's
     `TERRAIN_VERSION_MINOR_MIN`. Updated tiles never reach those caches: only ArduPilot raising
     that minimum, or users clearing `/APM/TERRAIN`, replaces them. A bug that affects vehicle
     terrain needs coordinating with ArduPilot.
   - Keep QGC out of writing `.DAT` files. Doing so would mean tracking ArduPilot's block
     format and its `version_minor` stamp.

## Design Decisions

Each decision with the evidence for it. Changing one changes the tile format, which older QGC
builds would misread, so it cannot be published in place: it needs a new path (e.g.
`terrarium2/`) and a QGC release.

### Source: Copernicus only, no lidar overlay

Copernicus GLO-30 everywhere, with GLO-90 for the 25 cells GLO-30 omits. Tilezen's
higher-resolution lidar (US, parts of the EU, NZ) is not overlaid: the same single dataset then
feeds both the map and the vehicle, there are no mixed-source seams, and Tilezen is frozen around
2017.

### Max zoom: z13

The source is ~30 m everywhere. z12 pixels (38 m at the equator, 33 m at 30°) undersample it
below ~37° latitude, which also matters for vehicles using `TERRAIN_SPACING = 30`. z13 (19 m at
the equator) holds the full source resolution at every latitude.

### Height step: 0.25 m

ArduPilot stores and transmits terrain as whole meters (`int16`) on a `TERRAIN_SPACING` grid
(default 100 m, 30 m with SRTM1 data). Planned `TERRAIN_DATA` samples these tiles bilinearly
and rounds to whole meters, so the stored step and that rounding stack. Simulating that path on
~2.6M land points of the sample areas:

| Stored step | `TERRAIN_DATA` values changed by 1 m | Worst error vs exact | Mean bias | Mean land z12 tile |
| ----------- | ------------------------------------ | -------------------- | --------- | ------------------ |
| 1/256 m (full precision) | 0% | 0.50 m | 0 | 113 KB |
| 1/16 m | 1% | 0.53 m | 0 | 75 KB |
| **0.25 m** | **4%** | **0.63 m** | **0** | **53 KB** |
| 0.5 m | 8% | 0.75 m | 0 | 42 KB |
| 1 m | 14% | 0.98 m | 0 | 31 KB |

"Changed" means the whole-meter value differs from rounding the unrounded height. Rounding is to
the nearest step, so no option biases terrain low, the unsafe direction. 0.25 m adds at most
12.5 cm, small against Copernicus's own accuracy (< 4 m absolute, < 2 m relative on gentle
slopes, 90% of points) and the 2–3 m bias between Copernicus and ArduPilot's data. It removes
noise digits that make up about half of a full-precision tile. 1 m was rejected because the
double rounding reaches ~1 m. A 0.25 m step is also too small to show as banding in GeoMap's
lighting.

### Vertical datum: EGM2008, unconverted

Copernicus heights are above the EGM2008 geoid; ArduPilot's SRTM/AW3D30 data uses EGM96.
Measured differences: median 0.2 m globally, 95% within 1.1 m, 99% within 2.5 m, at most ~9.5 m
(Antarctica); 0.1–2.3 m at the sample areas. ArduPilot's arming correction (`TERRAIN_OFS_MAX`)
absorbs a constant local offset, GPS receivers use various geoid models anyway, and QGC already
sends EGM2008 heights through the Auterion source. Recorded in `tilejson.json`.

### Surface model

Copernicus is a DSM, as is ArduPilot's AW3D30 and today's Auterion source. Trees, buildings and
snow are part of the surface.

## Source Data

[Copernicus DEM](https://registry.opendata.aws/copernicus-dem/) on AWS Open Data, read directly
over HTTPS as Cloud Optimized GeoTIFFs:

- **GLO-30** (`copernicus-dem-30m`, eu-central-1): 26,450 1°×1° cells.
- **GLO-90** (`copernicus-dem-90m`) fills the 25 cells GLO-30 omits (Armenia, Azerbaijan and
  their borders). Without it those mountains would be written as sea level.
- Cells absent from both `tileList.txt` files are ocean. Inside a cell, ocean is stored as 0 m,
  not nodata.

Each cell's pixel centers sit on whole multiples of its resolution (GeoTIFF
`AREA_OR_POINT=Point`; GDAL reports the half-pixel area overhang). A pixel centered on a whole
degree is the top row (or first column) of the cell south (or east) of it. Latitude spacing is
1″ (30.9 m) everywhere in GLO-30; longitude spacing widens with latitude so ground spacing stays
near 30 m:

| Latitude | 0–50° | 50–60° | 60–70° | 70–80° | 80–85° | 85–90° |
| -------- | ----- | ------ | ------ | ------ | ------ | ------ |
| Longitude spacing | 1″ | 1.5″ | 2″ | 3″ | 5″ | 10″ |

GLO-90 cells are 3″×3″. Where a GLO-90 cell lies north of a GLO-30 cell, their pixel areas leave
a one-row gap; the reverse overlaps.

## Pipeline

The unit of work is one **z9 job tile** (16×16 z13 tiles, 4096×4096 px); 262,144 jobs cover the
globe and run in a process pool. Per job ([`generate.py`](generate.py)):

1. **Select cells** intersecting the job plus a small buffer ([`copernicus.py`](copernicus.py)).
   Longitudes wrap at the antimeridian: a job ending at 180°E also reads the cell at 180°W,
   placed 360° east, so its last pixels interpolate across the line instead of becoming sea
   level. No cells: write the shared sea PNG for all 341 tiles and stop.
2. **Group** the cells by source lattice (a longitude band, or GLO-90) and merge each group into
   a grid on its own lattice, padded past the job edge by the 100-pixel void-fill radius and
   four of its pixels. A grid's extent depends only on its resolution, so every job around a
   cell sees the same data.
   - Cells sharing a resolution are merged by exact pixel copy (same lattice), which keeps
     bilinear interpolation continuous across 1° cell edges instead of clamping at each file's
     edge.
   - Each cell is pasted from a whole-pixel window read; a cell off its group's lattice fails the
     job. `rasterio.merge` computed windows a hair off whole pixels, so GDAL resampled every read
     (about 45 s of a real Alps job, now about 0.2 s).
   - Different lattices are never merged directly: rasterio rounds coarser source windows to
     whole pixels, shifting that data by up to a pixel (~23 m at 60°N).
   - A group's missing nodes (the neighboring band or GLO-90 cell) are resampled from the other
     groups with plain bilinear, so the cell blends with its neighbors only within a pixel of
     their shared edge.
3. **Fill voids.** Any node of a group inside one of its own cells (by whole 1° cells, not file
   extents) that still has no height is a void: source nodata. Voids within 100 source pixels
   (~3 km) of data are interpolated. A larger void **fails the job** rather than being written
   as sea level. Ocean (no cell) is never filled.
4. **Warp** each group to the 4096×4096 Web Mercator grid with exact z13 pixel bounds, bilinear,
   and take every pixel from the group whose cell contains it. Each pixel is therefore
   interpolated once, from native data, whatever its neighbors read: resampling all cells onto
   one lattice would interpolate a cell twice in a job that also reads a finer neighbor, and
   not in the job next to it.
5. **Cut** z13 tiles from the grid, then build z12..z9 by 2×2 averaging the float heights (never
   the encoded RGB, whose encoding is not linear). Heights are rounded to the 0.25 m step only
   when each tile is encoded.
6. **Mark** the job done in `<out>/_done/` with its status (`ocean` or the cell count), so
   reruns resume.

`overviews` then builds z8..z0 from the z9 tiles by decoding, averaging and re-encoding; missing
children count as sea level. Each zoom's tiles are built in a process pool (`--workers`, default
every CPU) before the next coarser zoom starts.

## Publishing and Updates

There is one production tile tree, at the root of the private `qgc-terrain-assets` bucket
(us-west-2, served only through the CloudFront distribution `E2AZ33YAOTUR6`, no ACLs). It is
updated in place: a generator fix overwrites the affected tiles rather than publishing a second
copy of the ~1.8 TB tree. Test uploads go under a folder instead (`--prefix validation`), which
serves at `https://terrain.assets.qgroundcontrol.com/validation/terrarium/...`.

`upload` (`--production` or `--prefix NAME`, plus a required `--data-version`) runs three steps,
stopping at the first failure:

1. **Tiles:** `aws s3 sync` with a one-day max-age. Remote tiles missing locally are deleted only
   with `--mirror`, which is refused for production: a full build writes every key, and a partial
   one would wipe the rest.
2. **CloudFront invalidation** of `<target>/terrarium/*`, waiting until it completes.
3. **Metadata:** `ATTRIBUTION.txt`, then `tilejson.json` with `version` set to `--data-version`
   and the `notice`, both with a five-minute max-age. `tilejson.json` goes last, so the new
   version appears only once everything else is in place.

The order is the safeguard. QGC clears its cached tiles when the data version changes (see
[Required QGC Client Changes](#required-qgc-client-changes)). If CloudFront could still serve
an old tile after the new version is visible, a client would fetch and cache it under the new
version for good. If the invalidation fails or its wait times out, the metadata is not uploaded;
rerun `upload` to finish.

Before anything is uploaded to production, `upload` also refuses:

- **A partial build:** every z9 job must have a done marker, and the z0 tile must be newer than
  the newest one. Overviews of a partial build would replace the world's z0–z8 tiles with mostly
  sea level, and overviews older than the jobs would publish stale low zooms.
- **A version that does not rise:** `--data-version` must be higher than the `version` in the
  live `tilejson.json` (read from S3, not CloudFront). Reusing a version would skip every
  client's cache refresh. Rerunning after a failed invalidation is fine: that run never
  published its version.
- **An unchecked build:** a passing [whole-build check](#whole-build-check) must be newer than
  the overviews, so every tile was checked after the last change.

**Data version:** `MAJOR.MINOR.PATCH` in TileJSON's `version` field, compared numerically. QGC
only checks it for equality. Format changes are not updates (see
[Design Decisions](#design-decisions)).

**Notice:** production uploads require `--notice TEXT`, which QGC shows when it discards its
cached tiles for the new version. `--notice-severity critical` is for errors users must act on:
say what was wrong, where and since when, and what to do (re-plan terrain-following missions;
ArduPilot users clear `/APM/TERRAIN`, since updated tiles never replace vehicle terrain caches).
The default `info` is for routine improvements. Each upload replaces the notice, so a client
that skips a release never sees the skipped notice: restate any guidance users must still act
on in later critical notices.

**Publishing a fix:**

1. Fix the generator and add a regression test (see [Unit Tests](#unit-tests)).
2. Regenerate, run `overviews`, then `check`, and `validate` the affected areas and a random
   sample.
3. `diff` the new build against the previous one to confirm the fix changed only what it should
   (see [Comparing Builds](#comparing-builds)).
4. Upload to a test prefix and inspect it, then `upload --production` with a higher
   `--data-version` and a `critical` notice.

Missing keys return **403**, not 404: CloudFront's access to the bucket excludes listing.

### Modules

| Module | Purpose |
| ------ | ------- |
| [`tilemath.py`](tilemath.py) | Web Mercator tile bounds and lon/lat conversions; `MAX_ZOOM`, `JOB_ZOOM` (pure Python) |
| [`heights.py`](heights.py) | Sea level and the 0.25 m height step, shared by the encoder and the metadata (pure Python) |
| [`copernicus.py`](copernicus.py) | Cell naming, tile lists, GLO-30/GLO-90 cell map, cell selection |
| [`terrarium.py`](terrarium.py) | Terrarium encode/decode, 0.25 m rounding, height averaging, PNG I/O |
| [`generate.py`](generate.py) | Per-lattice grids, void fill, warp, pyramid, overviews, process pool |
| [`jobs.py`](jobs.py) | Tile paths (`TILES_DIR`), job done markers, bounded job scheduling and validation job selection (pure Python) |
| [`validate.py`](validate.py) | Source, ArduPilot and Tilezen checks, their pass/fail thresholds, per-job worker pool |
| [`check.py`](check.py) | Whole-build check of every tile: structure, seams, height jumps |
| [`compare.py`](compare.py) | Tile-by-tile comparison of two builds |
| [`stats.py`](stats.py) | Tile count and size report per zoom |
| [`ardupilot.py`](ardupilot.py) | Independent numpy reader for ArduPilot's SRTM1 `.hgt` cells |
| [`metadata.py`](metadata.py) | `tilejson.json` and `ATTRIBUTION.txt` contents |
| [`upload.py`](upload.py) | `aws` commands for tile sync, CloudFront invalidation and metadata; cache policy |
| [`cli.py`](cli.py) | `generate`, `overviews`, `validate`, `stats`, `check`, `diff`, `upload` subcommands |

## Safeguards Against Known Terrain Bugs

ArduPilot's terraingen shipped several conversion bugs that put wrong terrain on vehicles
([#15252](https://github.com/mavlink/qgroundcontrol/issues/15252)). Each class and how this
pipeline handles it, plus one class this pipeline had itself (the antimeridian):

| Bug class | Risk here | Prevention | Caught by |
| -------------- | --------- | ---------- | --------- |
| Land written as 0 m after an ocean tile (state leaking between tiles) | Missing cells or gaps read as ocean | Each job starts from fresh arrays in its own process; GLO-90 fills GLO-30's gaps; coverage is by whole cells | Unit tests; source check; ArduPilot land-as-sea count; `check` seams on every tile |
| Voids written as 0 m | Source nodata becomes sea level | Voids interpolated; unfillable voids fail the job | Unit tests (small void filled, large void fails, ocean untouched); `check` seams on every tile |
| Wrong interpolation at 1° cell edges | Clamping at each file's edge | Cells of one lattice merged into one grid per job | Unit test (exact ramp across borders); source check samples cell borders |
| Land written as 0 m along the antimeridian (found in this pipeline, fixed) | The cell across 180° never read, so the last pixels had no data on one side | Cell selection wraps longitudes; that cell is placed 360° away | Unit test (exact ramp up to 180°E); `taveuni-180e`/`taveuni-180w` sample areas; `check` seams across 180° |
| Horizontal drift from two-step offset math | Misregistered pixels, worse at high latitude | GDAL does all georeferencing from exact tile bounds; mixed lattices warped, not merged | Unit tests (exact ramp across a longitude band); source check; ArduPilot offset |
| Old bad data indistinguishable from fixed data | Stale tiles in CloudFront and client caches | Data version in `tilejson.json`, required to rise and published only after the CloudFront invalidation completes; QGC clears its tile cache when the version changes and shows the required notice | `upload` order, version and notice tests |

## Validation

`terrain_tiles.py validate` checks completed jobs and exits nonzero on any problem. Run it on
the sample areas before a global run, and on a random sample of jobs before uploading to
production:

- No option validates every completed job; `--sample-jobs N` validates N random land jobs
  (reproducible with `--seed`; ocean jobs have nothing to compare); `--job Z/X/Y` (repeatable)
  validates specific jobs.
- Each worker decodes and checks one job at a time and only the result rows are kept, so memory
  stays around 1–1.5 GB per worker regardless of job count. `--workers N` (default 1) validates N
  jobs at once, each in its own process; the checks mostly wait on downloads, so this divides the
  wall time of a `--sample-jobs` run by about N. Source samples are seeded by `--seed` and the
  job, so results do not depend on the worker count.
- A job whose tiles cannot be read, or a source or ArduPilot check that fails outright (e.g. a
  download that outlasts its retries), is reported as a problem for that job and the run
  continues; a failed Tilezen check only prints a warning. Downloads (`common.net`) retry
  connection errors, timeouts and 5xx, 408 and 429 responses; other HTTP errors fail at once.

1. **Copernicus source (correctness of our pipeline).** Decoded z13 pixels against a
   point-bilinear sampler written independently of the generator: it reads the 2×2 source
   pixels around each pixel center directly, crossing into the east/south neighbor cell at
   edges. Each job gets exactly `--samples` samples (default 400): half within two pixels of
   the 1° cell borders it crosses, the rest uniform (all uniform if it crosses none). z13 pixels
   are finer than the source everywhere, so the expected error is the rounding alone: mean
   ~0.06 m, max 0.125 m. Jobs on a spacing band or GLO-90 border show a few meters, up to ~15 m,
   because the sampler leaves out neighbors on a different lattice. **Fails above 50 m**, which
   only a pipeline bug produces.
2. **ArduPilot AW3D30 (independent dataset).** Every pixel of the job against ArduPilot's SRTM1
   cells from `terrain.ardupilot.org` (JAXA AW3D30 repackaged as `.hgt`), read by a plain-numpy
   pixel-is-point sampler that shares no georeferencing code with the generator. Reports bias,
   land written as sea, pixels more than 50 m too low (the unsafe direction), and a least-squares
   horizontal offset in meters, reported as `n/a` on terrain too planar to tell a shift from a
   vertical bias. Too-low pixels are only judged where the slope is under 0.5; on
   steeper ground the datasets' horizontal offset alone produces such differences. **Fails** if
   land-as-sea exceeds 1% of land, too-low exceeds 5% of gentle land, or the offset exceeds
   40 m.
3. **Tilezen (informational).** Sub-pixel offset of today's client dataset on the most rugged
   z13 tiles, to see how far terrain moves when the client switches.

### Whole-build Check

`validate` measures accuracy on a sample of jobs. `check` reads **every** tile, so a fault in a
job the sample missed cannot reach an upload; a production upload requires a passing check newer
than the overviews. Modeled on the tools ArduPilot added after the terraingen bugs
(`offline_check.py`, `find_steep.py`), it reports:

- **Structure:** each tile of a completed job and every parent of an existing tile exists, is a
  readable 256×256 RGB PNG, has heights on the 0.25 m steps within −500..9000 m, and is the
  shared sea PNG if it is all sea.
- **Seams:** at every tile boundary, including between independently generated jobs and across
  the antimeridian, the mean jump across the boundary must stay within 3× the mean step beside it
  (+0.5 m for rounding). Real terrain is as steep across a boundary as beside it; a misplaced,
  stale or zeroed tile is not.
- **Jumps:** neighboring pixels more than 1,000 m apart (or 2× the pixel's ground size at coarse
  zooms, where pixels average large areas).

Overview tiles with missing children (a partial build, filled with sea level by design) get the
structural checks only. Each job reads the edge tiles of its east and south neighbors, so memory
stays per job. It costs about 1.5 ms of CPU per tile (measured on the sample areas), so roughly
40 minutes on 64 vCPUs for a full build. On the sample areas the check passes, and it caught each
of three faults planted in real tiles (two swapped z13 tiles, a z13 tile zeroed on land, a z12
tile copied from another continent) on all four sides.

### Comparing Builds

`diff --out NEW --against OLD` compares two local builds tile by tile: per zoom, how many tiles
are identical, changed (with the largest height change), added, removed or unreadable, then the
largest changes. An unreadable tile counts as changed instead of stopping the run; `check` says
why. `--list FILE` streams the changed and added tile paths to a file, which is what an upload
of only the changes would send (see [Open Items](#open-items)). Both builds must be local; the
previous one is gone once its instance is, so keep its volume or sync it down before
regenerating.

### Expected Differences Between Datasets

- **~8–20 m horizontal offset, mostly north–south.** Copernicus sits about 0.3–0.45 of a 1″
  pixel north of both AW3D30 and Tilezen. Comparing the raw Copernicus cells to AW3D30 on their
  native grids, with none of this code involved, shows the same offset, so it is a property of
  the datasets, not the pipeline.
- **A few meters of bias.** Different sensors and epochs (TanDEM-X radar 2011–2015, JAXA
  optical stereo 2006–2011).
- **Tens of meters on cliffs, coastlines, snow and ice.** Raw Copernicus is more than 50 m below
  AW3D30 on up to 3.3% of gentle land above 5,000 m near Everest (radar penetration into snow,
  glacier change, AW3D30 void fill), but under 0.2% below 5,000 m. Hence the 5% limit for
  too-low pixels; real failures are far above it.

Run against tiles from the first version of this pipeline, which treated GLO-30's missing cells
as ocean, the validator flagged the Aragats job: 97.6% of its land written as sea.

### Sample Areas

`generate --area all` builds 17 jobs covering:

| Area | Covers |
| ---- | ------ |
| `alps`, `everest`, `aconcagua` | High relief, each hemisphere, high-altitude snow and ice |
| `sf-coast` | Coastline and open ocean |
| `kansas` | Flat plains |
| `jotunheimen`, `svalbard` | 2″ and 3″ longitude bands, fjords, glaciers |
| `oslo-60n` | A job spanning the 1.5″/2″ band boundary |
| `aragats` | Inside a GLO-90 gap-fill cell |
| `armenia-40n` | GLO-90 north of GLO-30, where the pixel areas leave a gap to fill |
| `caucasus-42n` | GLO-30 north of GLO-90, where the pixel areas overlap |
| `taveuni-180e`, `taveuni-180w` | Land crossed by the antimeridian: the jobs either side of 180° |

## Size and Cost Estimates

For a full z0–z13 build. These are planning estimates for the infrastructure sign-off in
[#15090](https://github.com/mavlink/qgroundcontrol/issues/15090); measure a trial run on the
real instance before relying on the run time.

### Size

Mean tile sizes measured on the sample areas (`stats`). The samples are land-heavy and
mountainous, so these are upper estimates; flat land and ice compress better.

| Zoom | 13 | 12 | 11 | 10 | 9 |
| ---- | -- | -- | -- | -- | - |
| Mean land tile | 37 KB | 48 KB | 61 KB | 74 KB | 84 KB |
| Land tiles (approx.) | 32.5M | 8.1M | 2.0M | 0.5M | 0.13M |
| Total | 1,190 GB | 390 GB | 125 GB | 37 GB | 11 GB |

z13 tiles compress best because they upsample the ~30 m source. "Land tiles" are tiles touching
a Copernicus cell (8.1M at z12, counted; the other zooms scale by 4). Web Mercator stretches
Greenland and Antarctica, so this is about half of each zoom.

| | Amount |
| --- | ------ |
| Objects, z0–z13 | 89.5M (every key exists, ocean included) |
| Land tiles | ~1.75 TB |
| Ocean tiles | ~46M × 564 B ≈ 26 GB |
| Total | **~1.8 TB** |

### Cost

AWS list prices assumed (checked October 2026; check again before sign-off): S3 Standard
us-west-2 $0.023/GB-month and $0.005 per 1,000 PUT; inter-region transfer from eu-central-1
$0.02/GB; c7gd.16xlarge in eu-central-1 ~$3.36/hour (64 vCPU, 128 GiB, 2×1.9 TB NVMe);
CloudFront $0.085/GB and $0.01 per 10,000 HTTPS requests beyond the always-free 1 TB and 10M
requests a month.

| One-time | Basis | Cost |
| -------- | ----- | ---- |
| S3 PUT requests | 89.5M objects | ~$450 |
| Transfer eu-central-1 → us-west-2 | ~1.8 TB | ~$36 |
| Reading Copernicus | Same-region AWS Open Data | $0 |
| EC2 c7gd.16xlarge | ~1–2 days, see below | ~$80–160 |
| **Total** | | **~$570–650** |

| Ongoing | Basis | Cost |
| ------- | ----- | ---- |
| S3 storage | ~1.8 TB | ~$41/month |
| CloudFront within the free tier | ≤ 1 TB and ≤ 10M requests a month | $0 |
| CloudFront beyond the free tier | Per 1M z13 tile requests (~37 GB) | ~$4 |

Requests run out before bytes: 10M free requests is about 370 GB of z13 tiles.

**Run time.** Generation needs roughly 10 CPU-seconds per land job and much less per ocean job,
so about 400 CPU-hours for the ~127k land jobs: about 6 hours on 64 vCPUs. Uploading 89.5M
objects dominates. The upload runs from eu-central-1 to us-west-2, where a request takes about
150 ms, so the AWS CLI's default of 10 concurrent requests manages under a hundred PUTs a second:
days to weeks for a full upload. Raise it, e.g.
`aws configure set s3.max_concurrent_requests 256 --profile <profile>`, for about 1,000–3,000
PUTs a second, roughly 8–25 hours.

**Disk.** Tiles are written locally, then uploaded, so the run needs ~1.8 TB of scratch.
A c7gd.8xlarge (1.9 TB NVMe, ~$1.68/hour) is too tight; use a c7gd.16xlarge. Uploading in
batches and deleting is not an option: a production upload takes one complete local build.

For comparison, the original #15090 estimate (z12, full precision) was 22.4M objects, ~$112 in
PUT fees and ~400 GB.

## Unit Tests

[`../tests/test_terrain.py`](../tests/test_terrain.py) (stdlib only) covers tile math, cell
naming and selection, GLO-90 gap filling in the cell map, bounded job scheduling, the CLI's
argument checks, and the upload targets, cache policy, publish order and production checks;
[`../tests/test_net.py`](../tests/test_net.py) covers the download retry policy. [`../tests/test_terrain_pipeline.py`](../tests/test_terrain_pipeline.py) needs the
`terrain` group and skips without it. Its core tests write small synthetic Copernicus-style
cells holding a **linear height ramp** in lon/lat: bilinear interpolation reproduces a linear
field exactly, so every generated pixel must equal the analytic ramp at its center. That checks
mosaic, lattice alignment, warp and Mercator math together, with no network. Cases:

- Same-resolution cells: exact across 1° borders.
- Same-resolution merge: reads only whole-pixel windows and matches `rasterio.merge` bit for bit
  on bounds where its windows are fractional; a cell off the lattice is rejected.
- Cells in two longitude bands: exact across the band boundary, and a job's heights do not
  change when the cells it buffers include another lattice (checked on a nonlinear field, which
  a ramp cannot do).
- GLO-90 beside GLO-30: the lattice gap is filled, never sea.
- Missing cells stay sea level, and void filling never spreads land into them.
- Small voids are interpolated; unfillable voids fail the job.
- Rounding: nearest 0.25 m step, error at most half a step, no bias; near-zero tiles become sea.
- The validation sampler interpolates across east and south cell edges; source samples match the
  requested count, and a job with none to compare is reported instead of crashing.
- A broken worker pool counts every unfinished job as failed; in `validate`, `check` and `diff`
  a failing or broken worker is reported as a problem instead of ending the run.
- Validation gates: each source and ArduPilot threshold at and past its limit, a planted
  land-as-sea block found by the ArduPilot comparison, and the exit status that follows; the
  ArduPilot cell cache keeps downloads and 404s on disk and raises other HTTP errors.
- `check`, `stats` and `diff` run with rasterio unavailable.
- The antimeridian: a job ending at 180°E matches the ramp exactly up to its last pixel, the
  validation sampler interpolates across 180°, and `check` finds a seam planted across it.
- The whole-build check: each structural fault is reported; seams are flagged only when the jump
  dwarfs the steps beside it (not on steep real slopes); planted faults (missing tile, seam
  inside a job, seam between jobs, jump, missing parent) are found and block the passed marker;
  partial-build overviews are not flagged. `diff` counts identical, changed, added, removed and
  unreadable tiles and streams the changed and added ones to the list file.
- Encoding round trip, clamping, averaging heights not RGB, pyramid placement, overviews,
  TileJSON contents, offset fit recovery, and `.hgt` parsing.

```bash
cd tools && uv run --group test --group terrain pytest -q tests/test_terrain.py tests/test_terrain_pipeline.py
```

## Running

```bash
TT="uv run --project tools --group terrain python tools/terrain_tiles.py"
$TT generate --out build/terrain --area all
$TT validate --out build/terrain
$TT validate --out build/terrain --sample-jobs 200 --workers 16   # After a global run
$TT overviews --out build/terrain
$TT stats --out build/terrain
$TT check --out build/terrain                         # Every tile; required for production
$TT diff --out build/terrain --against build/terrain-previous --list changed.txt
$TT upload --out build/terrain --prefix validation --data-version 0.1.0 --profile qgc-terrain --dryrun
$TT upload --out build/terrain --production --data-version 1.0.0 --notice "Initial terrain data" --profile qgc-terrain --dryrun
```

- Jobs are bound by source reads. Run the global `generate --all` on EC2 in eu-central-1, next
  to the source buckets; from elsewhere a job takes 10–100 s.
- GDAL's block cache is capped at 512 MB per process (`GDAL_ENV`); its default, 5% of physical
  RAM, would be multiplied by every pool worker.
- A source read that stalls (no connection within 30 s, or under 1 byte/s for 60 s) fails its job
  instead of hanging the run; GDAL's defaults never time out. Rerun to retry it.
- `check`, `stats` and `diff` need only numpy and Pillow, not rasterio.
- Generation is resumable: rerunning skips jobs with a done marker (`--force` regenerates).
  Failed jobs leave no marker and are retried on the next run.
- `validate` caches tile lists in `<out>/_source/` and ArduPilot cells (~10 MB each) in
  `<out>/_reference/ardupilot/`.
- Always `--dryrun` an upload first: it lists the tiles to upload and, with `--mirror`, the
  tiles to delete. A dry run skips the CloudFront invalidation.
- For a full upload, raise the CLI's S3 concurrency first (see
  [Size and Cost Estimates](#size-and-cost-estimates)).

## Open Items

- Confirm the Copernicus licence attribution wording (`metadata.ATTRIBUTION_NOTICES`).
- Infrastructure sign-off for the global run: EC2 access in eu-central-1, an instance role for
  the bucket, and the costs above.
- Global-run disk: ~1.8 TB of output needs a ≥ 3.8 TB instance (c7gd.16xlarge), since a
  production upload takes one complete local build.
- The validation sampler leaves out neighbors on a different lattice at band and GLO-90 edges
  (renormalizing the rest), so it is approximate within one source pixel of those lines.
- Upload only changed tiles. Regenerated tiles are newer than their S3 copies, so `aws s3 sync`
  re-uploads every one of them, even byte-identical ones: ~$450 in PUTs for a full
  regeneration. `diff --list` already produces the changed and added tiles against a local copy
  of the previous build; what remains is uploading from that list (or comparing local MD5s with
  S3 ETags, which needs no previous build).
- Enable S3 versioning on the bucket, with previous versions expiring after ~30 days, so an
  update can be rolled back. Only objects an update changed keep an extra copy.
- Confirm the uploading identity (and the EC2 instance role) may create CloudFront invalidations
  on `E2AZ33YAOTUR6`.
- Make the [Required QGC Client Changes](#required-qgc-client-changes) before switching QGC to
  these tiles.

---

### Overview (`tools/debuggers/gdb-pretty-printers/README.md`)

# GDB Pretty Printers for Qt 6

This directory contains GDB pretty printers for displaying Qt types in a human-readable format.

## Setup

### Quick Setup (per session)

In GDB:

```gdb
source /path/to/qgroundcontrol/tools/debuggers/gdb-pretty-printers/qt6.py
```

### Permanent Setup

Add to your `~/.gdbinit`:

```gdb
python
import sys
sys.path.insert(0, '/path/to/qgroundcontrol/tools/debuggers/gdb-pretty-printers')
from qt6 import register_qt_printers
register_qt_printers(None)
end
```

### VS Code Setup

The tracked launch template (`.vscode/launch.default.json`, installed as `launch.json` by
`just vscode`) only enables GDB's built-in pretty-printing. Use the `~/.gdbinit` setup above to load
these printers in VS Code GDB sessions.

## Supported Types

- **Strings**: QString, QByteArray
- **Containers**: QList, QVector, QMap, QHash
- **Geometry**: QPoint, QPointF, QSize, QSizeF, QRect, QRectF
- **Other**: QVariant, QUrl, QSharedPointer

## Example Output

```text
(gdb) print myString
$1 = "Hello, World!"

(gdb) print myList
$2 = QList<...> (size=3) = {1, 2, 3}

(gdb) print myPoint
$3 = (100, 200)

(gdb) print myRect
$4 = [(0, 0) - (799, 599)]

(gdb) print myVariant
$5 = QVariant(int) = 42

(gdb) print myMap
$6 = std::map with 2 elements = {[1] = 2, [3] = 4}
```

QMap prints through GDB's libstdc++ `std::map` printer. QVariant prints the stored value when GDB
has debug info for its type, and only the type name otherwise; QUrl needs QtCore debug symbols.

## LLDB Support

These printers are GDB-only; no LLDB formatters are provided here. Qt Creator's debugger ships its
own Qt type helpers for LLDB (`share/qtcreator/debugger/lldbbridge.py` in the Qt Creator install).

---

### Overview (`tools/generators/config_qml/README.md`)

# Vehicle Config QML Generator

Generates QML vehicle configuration pages from JSON definitions.

Each `<Name>.VehicleConfig.json` file in a `VehicleConfig/` directory is
converted to `<Name>Component.qml` in the build tree at CMake configure time.

## Quick start

```bash
python3 tools/generators/config_qml/generate_pages.py \
    --pages-dir src/AutoPilotPlugins/APM/VehicleConfig \
    --output-dir build/generated-config-qml
```

`--dry-run` prints the generated QML instead of writing files. `--list-outputs` prints the QML file
names that would be generated; CMake uses it to compute the output list.

`tools/tests/test_config_qml_snapshots.py` pins the QML generated from every
`src/AutoPilotPlugins/*/VehicleConfig/*.VehicleConfig.json`. After an intended page or generator
change, refresh it with
`uv run --frozen --project tools --group test pytest -q tools/tests/test_config_qml_snapshots.py --snapshot-update`
and review the `.ambr` diff.

---

## JSON schema

### Top-level object

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `fileType` | `"VehicleConfig"` | yes | Must be `"VehicleConfig"` |
| `version` | `1` | yes | Schema version, currently `1` |
| `constants` | object | no | Named literal values reusable in QML expressions |
| `params` | object | no | Named parameter fact declarations (see [params](#params)) |
| `bindings` | object | no | Named QML property bindings available to the page |
| `imports` | array of strings | no | Extra QML `import` lines to emit |
| `controllerType` | string | no | QML type for the controller; default `FactPanelController` |
| `sections` | array of [Section](#section) | yes | Page sections, rendered top-to-bottom |

### `params`

Declares parameter facts the page needs. Each key becomes a QML property; each
value is either a parameter name string or an object:

```json
"params": {
    "rateParam": "LOG_FILE_RATEMAX",
    "backendParam": {
        "name": "LOG_BACKEND_TYPE",
        "required": false,
        "existsOnly": false
    }
}
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | string | — | MAVLink parameter name |
| `required` | bool | `false` | `true` → `getParameterFact(-1, name)` (reports the parameter as missing); `false` → `getParameterFact(-1, name, false)` (silently returns null) |
| `existsOnly` | bool | `false` | `true` → `parameterExists(-1, name)` — exposes a boolean instead of a fact |

Shorthand: `"rateParam": "LOG_FILE_RATEMAX"` is equivalent to `{ "name": "LOG_FILE_RATEMAX", "required": false }`.

Parameter names, here and in a control's `param`, must match `[A-Za-z0-9_]+`, optionally prefixed
with `noremap.`; repeat `paramPrefix`/`probePostfix`/`enableParam` and `linkedParams` keys follow
the same rule.

---

### `section`

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | yes | Section heading (translatable) |
| `image` | string | no | `qrc:` path for a section icon |
| `showWhen` | string | no | QML expression; section hidden when falsy |
| `keywords` | array of strings | no | Extra search terms for the section filter |
| `controls` | array of [Control](#control) | yes* | Controls to render |
| `component` | string | no | Escape hatch — name of a hand-written QML component to embed instead of generating controls |
| `repeat` | [Repeat](#repeat) | no | Repeat this section for each indexed parameter instance |

\* Not required when `component` is set.

---

### `control`

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `param` | string | one of `param`/`setting`/`component`/`actionButton` | MAVLink parameter name |
| `setting` | string | — | Settings fact path, e.g. `"flyViewSettings.showAltimeter"` |
| `label` | string | no | Override label; empty → uses `fact.shortDescription` |
| `control` | string | no | Control type (see [Control types](#control-types)); default `textfield` |
| `optional` | bool | see description | Parameter may not exist. Uses `getParameterFact(-1, name, false)`. Control is hidden (`visible: fact !== null`) when the parameter is absent. Set automatically unless `params` declares the parameter `required: true`. |
| `showWhen` | string | no | Extra QML visibility expression. When combined with `optional: true`, the control is only visible when both the parameter exists *and* this expression is truthy. |
| `enableWhen` | string | no | QML expression bound to `enabled` |
| `indent` | bool | `false` | Indent control with `Layout.leftMargin` |
| `warning` | bool | `false` | (label only) Render text in warning color |
| `smallFont` | bool | `false` | (label only) Use smaller font |
| `component` | string | no | Inline escape hatch — hand-written QML component name (with `"control": "component"`) |

---

### Control types

The `control` key selects the generated widget. When omitted, the generator
emits a text field; it does not auto-detect a widget from parameter metadata.

| `control` value | Widget | Extra keys |
| --- | --- | --- |
| *(omitted)* | `LabelledFactTextField` (same as `textfield`) | — |
| `combobox` | `LabelledFactComboBox` | `enumValues` for manual value/label list |
| `textfield` | `LabelledFactTextField` | `description` for optional help text below the field |
| `checkbox` | `FactCheckBoxSlider` | — |
| `toggleCheckbox` | `QGCCheckBoxSlider` with custom logic | `toggleCheckbox` |
| `slider` | Slider with optional enable-checkbox and adjacent button | `sliderMin`, `sliderMax`, `enableCheckbox`, `button` |
| `factslider` | `FactSlider` in a `QGCGroupBox` | `sliderFrom`, `sliderTo`, `majorTickStepSize`, `decimalPlaces`, `description`, `linkedParams` |
| `radiogroup` | Label + radio buttons | `options`, `raw` |
| `bitmask` | `FactBitmask` | `firstEntryIsAll` |
| `bitmaskCheckbox` | `FactBitMaskCheckBoxSlider` | `bitMask` |
| `dialogButton` | Button that opens a popup dialog | `dialogButton` |
| `actionButton` | Standalone button calling a controller method | `actionButton` |
| `label` | Static text label | `warning`, `smallFont` |
| `component` | Hand-written QML component | `component` |

#### `combobox` with `enumValues`

When a parameter has no enum metadata (e.g. the firmware doesn't expose it),
supply a manual value/label list:

```json
{
    "param": "EK3_LOG_LEVEL",
    "control": "combobox",
    "optional": true,
    "enumValues": [
        { "value": 0, "label": "Full logging" },
        { "value": 1, "label": "XKF4 scaled innovations only" },
        { "value": 2, "label": "XKF4 and GSF" },
        { "value": 3, "label": "Disabled" }
    ]
}
```

`value` must be a number or string. `label` must be a string.

#### `textfield` keys

| Key | Type | Description |
| --- | --- | --- |
| `description` | string | Help text rendered below the field in small font. Wraps the control in a `ColumnLayout`. |

#### `factslider` keys

| Key | Type | Description |
| --- | --- | --- |
| `sliderFrom` | number | Minimum value override |
| `sliderTo` | number | Maximum value override |
| `majorTickStepSize` | number | Tick interval |
| `decimalPlaces` | number | Decimal precision |
| `description` | string | Help text rendered above the slider |
| `linkedParams` | object | `{ "PARAM_NAME": "expression" }` — params updated `onValueChanged` |

`linkedParams` expression: `value` refers to the slider's current value.

```json
"linkedParams": {
    "ATC_RAT_PIT_P": "value",
    "ATC_RAT_PIT_I": "value * 2"
}
```

#### `slider` keys

| Key | Type | Description |
| --- | --- | --- |
| `sliderMin` | number | Minimum value override |
| `sliderMax` | number | Maximum value override |
| `enableCheckbox` | object | `{ "checked": "expr", "onClicked": "body" }` |
| `button` | object | `{ "text": "label", "onClicked": "body", "enabled": "expr" }` |

#### `radiogroup` keys

| Key | Type | Description |
| --- | --- | --- |
| `options` | array | `[ { "text": "Label", "value": 0 } ]` |
| `raw` | bool | Use `rawValue` instead of `value` for comparisons |

#### `bitmask` keys

| Key | Type | Description |
| --- | --- | --- |
| `firstEntryIsAll` | bool | First checkbox acts as an "all/none" toggle |

#### `bitmaskCheckbox` keys

| Key | Type | Description |
| --- | --- | --- |
| `bitMask` | number | The specific bitmask value this checkbox controls |

---

### `repeat`

Repeats a section for each indexed parameter instance (e.g. `BAT1_…`, `BAT2_…`).

| Key | Type | Description |
| --- | --- | --- |
| `paramPrefix` | string | Parameter prefix, e.g. `"BAT"` |
| `probePostfix` | string | Postfix used to discover instance count, e.g. `"_SOURCE"` |
| `startIndex` | number | First index (default `1`) |
| `firstIndexOmitsNumber` | bool | When `true`, index 1 → `"BAT"` not `"BAT1"` |
| `indexing` | string | Custom indexing mode (e.g. `"apm_battery"`) |
| `enableParam` | string | Param that must differ from `disabledParamValue` for section to show |
| `disabledParamValue` | string | Binding name for the "disabled" value |
| `disabledSection` | object | Companion section shown for disabled instances |

---

## Complete example

```json
{
    "fileType": "VehicleConfig",
    "version": 1,
    "sections": [
        {
            "title": "Storage",
            "keywords": ["logging", "log", "sd card"],
            "controls": [
                {
                    "param": "LOG_BACKEND_TYPE",
                    "label": "Logging backends",
                    "optional": true,
                    "control": "bitmask"
                },
                {
                    "param": "LOG_MAX_FILES",
                    "label": "Maximum retained log files",
                    "optional": true
                }
            ]
        },
        {
            "title": "Options",
            "controls": [
                {
                    "param": "EK3_LOG_LEVEL",
                    "label": "EKF3 logging verbosity",
                    "optional": true,
                    "control": "combobox",
                    "enumValues": [
                        { "value": 0, "label": "Full logging" },
                        { "value": 3, "label": "Disabled" }
                    ]
                }
            ]
        }
    ]
}
```

---

### Overview (`tools/generators/settings_qml/README.md`)

# Settings QML Generator

Generates QML application settings pages from UI definition JSON files.

The generator reads two types of inputs:

- `src/AppSettings/pages/SettingsPages.json` — the ordered list of pages
  shown in the Settings UI
- `src/AppSettings/pages/<Page>.SettingsUI.json` — the layout definition
  for each page

It also reads `src/Settings/*.SettingsGroup.json` to look up fact type metadata
(bool, enum, etc.) so it can auto-detect the right control type.

CMake runs the generator at configure time and writes the pages to the build
tree. To inspect the output, run it from the repository root:

```bash
python3 -m tools.generators.settings_qml.generate_pages \
    --output-dir build/generated-settings-qml
```

---

## `SettingsPages.json`

Defines the ordered list of pages in the Settings sidebar.

```json
{
    "version": 1,
    "fileType": "SettingsPages",
    "pages": [
        {
            "name": "General",
            "qml": "GeneralSettings.qml",
            "icon": "qrc:/res/QGCLogoWhite.svg",
            "pageDefinition": "General.SettingsUI.json"
        },
        { "divider": true },
        {
            "name": "Comm Links",
            "qml": "CommLinksSettings.qml",
            "icon": "qrc:/InstrumentValueIcons/usb.svg",
            "url": "qrc:/path/to/HandWritten.qml"
        }
    ]
}
```

### Page entry keys

| Key | Type | Description |
| --- | --- | --- |
| `name` | string | Display name in the sidebar |
| `icon` | string | `qrc:` path for the sidebar icon |
| `qml` | string | Output filename (e.g. `GeneralSettings.qml`) |
| `pageDefinition` | string | `*.SettingsUI.json` file to generate from |
| `url` | string | `qrc:` URL of a hand-written QML page (bypasses generation) |
| `visible` | string | QML expression; entry hidden when falsy |
| `divider` | bool | Insert a visual divider instead of a page entry |

A page entry must have exactly one of `pageDefinition` (generated) or `url` (hand-written).

---

## Custom build overlay

A custom build (`QGC_CUSTOM_DIR`) can pass `--custom-pages-dir <dir>` and
`--custom-settings-dir <dir>` (CMake does this automatically when
`<custom>/src/AppSettings/pages` / `<custom>/src/Settings` exist):

- `<custom pages dir>/SettingsPages.json` is merged into the stock page list.
  Overlay entries support three extra keys:

  | Key | Type | Description |
  | --- | --- | --- |
  | `insertAfter` | string | Insert the new page after the named stock page |
  | `insertBefore` | string | Insert the new page before the named stock page |
  | `remove` | string | Remove the named stock page (no other keys allowed) |

  An overlay entry whose `name` matches an existing page replaces it in place
  (positioning keys are not allowed on a replace).
- `*.SettingsUI.json` files in the custom pages dir are found first, so a file
  with the same name as a stock definition shadows it.
- `*.SettingsGroup.json` files in the custom settings dir provide fact metadata
  for custom groups registered at runtime via
  `SettingsManager::registerCustomSettingsGroup` (accessor = camelCase JSON
  stem plus `Settings`, e.g. `Custom.SettingsGroup.json` → `customSettings`).
  A custom stem that maps to a stock `SettingsManager` accessor is rejected.

`--list-outputs` prints the QML file names that would be generated; CMake uses
it to compute the output list.

See the `custom-example` build for a working reference.

---

## `*.SettingsUI.json`

Defines the layout of a single settings page.

### Top-level object

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `fileType` | `"SettingsUI"` | yes | Must be `"SettingsUI"` |
| `version` | `1` | yes | Schema version |
| `bindings` | object | no | Named QML property bindings (e.g. accessor aliases) |
| `imports` | array of strings | no | Extra QML modules to import (e.g. `QGroundControl.GPS`) |
| `groups` | array of [Group](#group) | yes | Setting groups displayed on the page |

`bindings` is the standard way to alias a long settings accessor:

```json
"bindings": {
    "_appSettings": "QGroundControl.settingsManager.appSettings"
}
```

The binding name is then available as a QML property anywhere on the page.

---

### `group`

A collapsible group with an optional heading.

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `heading` | string | no | Group heading (translatable) |
| `headingDescription` | string | no | QML expression for a dynamic description under the heading |
| `showWhen` | string | no | QML expression; group hidden when falsy |
| `enableWhen` | string | no | QML expression; group disabled when falsy |
| `sectionName` | string | no | Tree-nav display name; falls back to `heading` |
| `keywords` | array of strings | no | Extra search terms |
| `component` | string | no | Name of a hand-written QML component to embed instead of generating controls |
| `properties` | object | no | QML property bindings set on the `component` (same value rules as control `properties`) |
| `missing` | array of strings | no | Descriptions of complex UI not yet generated (documentation only) |
| `controls` | array of [Control](#control) | yes* | Controls in this group |

\* Not required when `component` is set.

---

### `control`

| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `setting` | string | yes, except `component`/`info` | Dotted path to the fact, e.g. `"appSettings.qLocaleLanguage"` |
| `label` | string | no | Override label; empty → uses `fact.label` |
| `control` | string | no | Explicit control type (see [Control types](#control-types)); auto-detected if omitted |
| `showWhen` | string | no | Extra QML visibility expression (combined with `fact.userVisible` via logical AND) |
| `enableWhen` | string | no | QML expression bound to `enabled` |
| `placeholder` | string | no | Placeholder text for text fields |
| `value` | string | no | QML expression shown by an `info` control |
| `component` | string | no | Hand-written QML component a `component` control embeds; it needs no `setting` |
| `properties` | object | no | Extra QML property bindings for `browse`/`scaler`/`component` controls (see below) |
| `enableCheckbox` | object | no | Enable-checkbox for sliders (see below) |
| `button` | object | no | Adjacent button for `slider` or `info` controls (see below) |

The `setting` value must be `"<settingsGroupAccessor>.<factName>"`, where the
accessor matches the C++ `SettingsManager` property
(e.g. `appSettings`, `flyViewSettings`, `autoConnectSettings`).

---

### Control types

When `control` is omitted, the generator reads the fact's type from
`*.SettingsGroup.json` metadata to auto-detect:

| Fact type | Default control |
| --- | --- |
| `bool` | `checkbox` |
| Has `enumStrings` | `combobox` |
| Anything else | `textfield` |

Explicit `control` values:

| Value | Widget |
| --- | --- |
| `combobox` | `LabelledFactComboBox` |
| `textfield` | `LabelledFactTextField` |
| `checkbox` | `FactCheckBoxSlider` |
| `slider` | Slider with optional enable-checkbox and adjacent button |
| `browse` | `LabelledFactBrowse` file/path browser (desktop only; pair with `showWhen: "!ScreenTools.isMobile"`) |
| `scaler` | `LabelledFactIncrementer` percentage scaler (for `uiScalePercent`) |
| `component` | The hand-written QML component named by `component`, inside the group |
| `info` | Read-only `LabelledLabel` showing `label` and `value`; an optional `button` adds an action |

#### `slider` extra keys

| Key | Type | Description |
| --- | --- | --- |
| `enableCheckbox` | object | `{ "checked": "expr", "onClicked": "body" }` |
| `button` | object | `{ "text": "label", "onClicked": "body", "enabled": "expr" }` |

#### `browse` / `scaler` / `component` extra keys

`properties` maps QML property names to values emitted into the control.
Booleans and numbers map to their QML literals; strings are emitted verbatim
as QML expressions. For example, to make a `browse` control pick a file
instead of a folder:

```json
{
    "setting": "viewer3DSettings.osmFilePath",
    "control": "browse",
    "showWhen": "!ScreenTools.isMobile",
    "properties": {
        "selectFolder": false,
        "nameFilters": "[ qsTr(\"OpenStreetMap files (*.osm)\") ]"
    }
}
```

---

### objectName conventions (UI test hooks)

Generated pages emit stable `objectName`s so QML UI tests (`test/QmlUITests/`,
via `QmlUITestBase::findVisibleItem`) can locate items without brittle
text/traversal matching:

| Item | objectName |
| --- | --- |
| Page root | `settingsPage_<PageName>` (characters outside `[A-Za-z0-9_]` stripped, e.g. `settingsPage_RemoteID`) |
| Group (`QGCGroupBox`, headed groups only) | `settingsGroup_<Heading>` (characters outside `[A-Za-z0-9_]` stripped, e.g. `settingsGroup_EUVehicleInfo`) |
| Text field (`LabelledFactTextField`) | `settingsTextField_<factName>` |
| Checkbox (`FactCheckBoxSlider`) | `settingsCheckBox_<factName>` |

Page names and headings are sanitized to `[A-Za-z0-9_]` before being embedded in
the objectName. Generation fails with an error if a heading sanitizes to an empty
string, or if two headings on the same page collapse to the same objectName —
rename one of the headings to resolve it.

The hand-written `SettingsPage.qml` content flickable is named
`settingsPageFlickable` for use with `QmlUITestBase::scrollIntoView`.
See `test/QmlUITests/RemoteIDSettingsUITest.cc` for a usage example.

---

## Complete example

```json
{
    "version": 1,
    "fileType": "SettingsUI",
    "bindings": {
        "_appSettings": "QGroundControl.settingsManager.appSettings"
    },
    "groups": [
        {
            "heading": "General",
            "keywords": ["language", "locale", "theme", "color scheme"],
            "controls": [
                {
                    "setting": "appSettings.qLocaleLanguage",
                    "control": "combobox"
                },
                {
                    "setting": "appSettings.indoorPalette"
                },
                {
                    "setting": "appSettings.audioVolume",
                    "control": "slider",
                    "enableCheckbox": {
                        "checked": "!_appSettings.audioMuted.rawValue",
                        "onClicked": "{ _appSettings.audioMuted.rawValue = !enableCheckBoxChecked }"
                    },
                    "button": {
                        "text": "Test",
                        "onClicked": "QGroundControl.testAudioOutput()",
                        "enabled": "!_appSettings.audioMuted.rawValue"
                    }
                },
                {
                    "setting": "appSettings.savePath",
                    "control": "browse",
                    "showWhen": "!ScreenTools.isMobile"
                }
            ]
        },
        {
            "heading": "Advanced",
            "component": "MyHandWrittenAdvancedGroup"
        }
    ]
}
```

---

### Overview (`src/VideoManager/VideoReceiver/GStreamer/README.md`)

# GStreamer Video Streaming

QGroundControl uses GStreamer for UDP RTP and RTSP video streaming in the Main Flight Display.

This guide covers the GStreamer receiver architecture, platform setup, and diagnostics. See
[CODING_STYLE.md](../../../../CODING_STYLE.md) for repository-wide source conventions,
[test/README.md](../../../../test/README.md) for the test framework, and
[tools/README.md](../../../../tools/README.md) for general build and tooling commands.

## Source Code Architecture

The pipeline is split into focused components (all in this directory):

| Component | Role |
| --- | --- |
| `GstVideoReceiver` | The `VideoReceiver` the app uses: runs the pipeline (source → tee → the branches below) and its only bus consumer on its own worker thread, and is the only component that reconnects (with backoff) after a stream failure. The components below are plain classes it owns; each header states which thread calls what. |
| `DecodingBranch` | Display branch behind the tee: inserts `decodebin3` once the source streams and reports decoding, the decoder name and the video size. |
| `RecordingBranch` | Recording branch behind the tee: one `splitmuxsink` file per recording, started at a keyframe and, for MP4/MOV, muxed so a crash leaves a playable file; `RecordingFinalizer` tells when the file is closed. |
| `StreamWatchdog` | Notices stalled source or decoder output on a monotonic clock, and paces reconnects with exponential backoff. |
| `PipelineDotDumper` | Writes the pipeline's `.dot` graphs; see [Pipeline Observability](#pipeline-observability). |
| `GstSourceFactory` | Constructs the source element from the stream URI and applies RTP jitter-buffer policy for `application/x-rtp` sources. |
| `GStreamerEnvironment` | Process-wide environment setup (plugin/scanner/helper/GIO paths, env hygiene) run before `gst_init()`. See [Runtime Environment Setup](#runtime-environment-setup). |
| `GStreamerHelpers` | Small utilities: decoder classification and rank overrides, tee branch heads, and taking a child element out of a running bin. |
| `GStreamerLogging` | Routes GStreamer's log output through QGC's logging and applies the persisted debug level. |
| `GstScoped` | RAII owners for transfer-full GStreamer returns so refs can't leak on early return. |

### `gstqgc` — custom GStreamer plugin

A QGC-owned plugin (`gstqgc/`) bridging the pipeline into Qt:

- **`qgcqvideosink`** — a `GstVideoSink` that pushes decoded frames into a Qt `QVideoSink`. Delivery keeps only the
  latest frame, so a stalled GUI thread never holds more than one undelivered frame (or the decoder buffer behind it).
  Its construct-only `gpu-zerocopy` property (passed down by the bin) enables zero-copy import of GPU memory, and its
  `frame-stats` property reports that sink's own frame counts per delivery path (`GstQgc::formatSinkStats` formats
  them); GstBaseSink's standard `stats` property is left as is.
- **`qgcvideosinkbin`** — a bin wrapping a format-restriction capsfilter plus `qgcqvideosink`.
- **`QGCQVideoSinkController`** — GUI-thread companion that mirrors negotiation/telemetry from the sink into QGC, and
  clears the frame its `QVideoSink` displays when the sink leaves the pipeline.

### `HwBuffers` — GPU paths

Per-platform import of decoded GPU frames without a copy, all sharing `GstHwFrameTexturesBase : QVideoFrameTextures`:

- **Platforms**: `dmabuf` (Linux/VA-API), `gl`, `vulkan`, `d3d` (D3D11/D3D12), `cuda`, `apple` (IOSurface), `android` (AHardwareBuffer).
- **`HwPathDescriptor`**: each backend describes its path once (memory match, buffer construction, cache reset,
  pool proposal, context bridge). `GstHwPathRegistry` lists the compiled ones in a constant array. The factory selects
  the first path whose descriptor matches a frame's memory, and the facade loops over the list for bus messages,
  context queries, restarts and resets.
- **`GstContextBridgeBase`**: GL, Vulkan, D3D11 and D3D12 bridges hand Qt's GPU device to GStreamer elements through
  `NEED_CONTEXT` and context queries.
- **`GstHwPathTelemetry`** tracks process-wide render-time counters (map failures, reuse hits, sync waits) for fallback
  diagnostics. A receiver's `GPU path stats` debug line pairs its own sink's `frame-stats` with them.
- **`CpuVideoFramePool`** recycles CPU-backed `QVideoFrame` storage (WebRTC-style slab) for the software-fallback path, avoiding per-frame allocation.
- **Build options**: `HwBuffers/CMakeLists.txt` probes one table row per path; the `QGC_GST_ENABLE_<PATH>` switches
  that compile a path out are listed in [`cmake/README.md`](../../../../cmake/README.md#gstreamer). Configure lists
  compiled paths that the sink can't reach at runtime: Android's glupload bin offers only GLMemory caps, and no bin
  advertises CUDAMemory or VulkanImage caps. Linux builds with gst-gl and DMABuf compile both GPU bins; see
  [GPU bin selection](#gpu-bin-selection).

## Build Configuration

- **Enable/disable**: Set the `QGC_ENABLE_GST_VIDEOSTREAMING` CMake option to `ON`/`OFF`.
- **Versions, checksums and plugins**: `gstreamer.version.<platform>`, `gstreamer.checksums`, `gstreamer.plugins` and the iOS CA-bundle pin live in [`.github/build-config.json`](../../../../.github/build-config.json), read through `qgc_config_read` ([`cmake/modules/BuildConfigReader.cmake`](../../../../cmake/modules/BuildConfigReader.cmake)). The download URLs (upstream, with the QGC S3 mirror as fallback) are templated from the version in [`cmake/GStreamer/Download.cmake`](../../../../cmake/GStreamer/Download.cmake); a package is verified against its checksum whichever host serves it. Linux uses the system GStreamer and treats `gstreamer.version.minimum` as its floor.
- **Configure options**:
  - `GStreamer_ROOT_DIR`: the SDK to use. Configure never writes it; the SDK it selected is recorded as `QGC_GST_RESOLVED_ROOT` in `CMakeCache.txt`.
  - `QGC_GST_VERSION`: overrides the build-config version (the minimum on Linux, the SDK to download elsewhere), as `<major>.<minor>.<micro>`.
  - `GStreamer_USE_STATIC_LIBS` / `GStreamer_USE_FRAMEWORK`: layout preferences with per-platform defaults; discovery overrides them for the SDK it finds.
  - `QGC_GST_BUNDLE_PLUGINS` (Linux, default `ON`): installs and AppImages bundle the system's plugins, plugin scanner and GIO modules (see [Linux](#linux)), and the app points GStreamer at them. Distribution packages set it `OFF`: the install then carries nothing of GStreamer, the app uses the system's plugins as they are, and the package must depend on the distribution's plugin packages. The Arch package then adds `gst-plugins-good`, `gst-plugins-bad-libs` and `gst-libav`; the DEB and RPM dependency scans do not see plugins, so their packagers list them. `OFF` requires `QGC_CREATE_APPIMAGE=OFF`.
  - `QGC_GST_USE_HOMEBREW` (macOS, default `OFF`): builds against Homebrew's GStreamer instead of the official SDK; such builds cannot be installed or packaged (see [macOS / Windows / iOS](#macos--windows--ios)).
  - `QGC_GST_CUDA_STUB_DIR` (Linux): a directory with a `cuda.h`, such as gst-plugins-bad's `gst-libs/gst/cuda/stub`, for building the CUDA GPU path without the CUDA Toolkit.
  - `GStreamer_REQUIRE_CHECKSUM` and `QGC_GST_DOWNLOAD_TIMEOUT` (the SDK download's wall-clock cap, default 1200 s; its other limits are the shared `QGC_DOWNLOAD_*` ones) control downloads; `GStreamer_DEBUG` traces target creation. `GStreamer_REQUIRE_CHECKSUM=OFF` lets an unpinned SDK version be checked against upstream's `.sha256sum` sidecar, or not at all; the Windows SDK installer still runs only when its checksum is pinned.
- **Plugin allowlist**: `gstreamer.plugins` (`common` plus the platform's key) decides what is registered and bundled. Android links the requested plugins that have a static archive; iOS registers only the requested plugins found in its merged archive, so the archive's other plugins (some GPL) stay out of the app; desktop static SDKs register the requested plugins that have a pkg-config module; desktop installs bundle the allowlisted plugins found in the SDK and check after install that those and the runtime-required ones arrived. Debug mobile builds add the MockLink test-stream encoders. A missing runtime-required plugin fails a mobile configure. Configure warns once about allowlist names the SDK or system does not ship as plugins (misspelled, not built for the platform, or not installed) and once about shipped ones a static build cannot link; missing hardware-specific or alternative decoders (`GSTREAMER_OPTIONAL_PLUGINS` in `PluginPolicy.cmake`, e.g. `qsv`, `va`, `libav`, `dav1d`) only appear in a status line, as do optional components (`RtspServer`, `Gl`, ...) that were not found.
- **License notice**: configure writes `LGPL-2.1.txt` and a `NOTICE.txt` naming the GStreamer version, its source code and, for an auto-downloaded SDK, the official packages and their SHA-256 (`Notice.cmake`). They ship in `share/licenses/gstreamer/` of desktop installs (macOS: `QGroundControl.app/Contents/Resources/licenses/gstreamer/`) and under `licenses/gstreamer/` in the Android APK assets and the iOS app bundle. The dependency SBOM CI attaches to each package ([`generate_cpm_sbom.py`](../../../../tools/ci/security/generate_cpm_sbom.py)) lists GStreamer from the same `QGC_GST_RESOLVED_*` cache entries.
- **CMake modules** ([`cmake/GStreamer/`](../../../../cmake/GStreamer/)): `Orchestrator.cmake` is the entry point this directory's `CMakeLists.txt` includes; its header lists inputs and outputs. `platform/<platform>.cmake` discovers each platform's SDK. The other modules are the helpers it includes, each described in its header. `tests/` holds script tests and a fake-SDK configure fixture, run with `ctest -L GStreamer`.

## Runtime Environment Setup

QGC configures GStreamer runtime paths before `gst_init()`:

- **Desktop (Linux/macOS/Windows)**: `GStreamer::prepareEnvironment()` sets plugin paths, scanner/helper paths, and GIO module paths when bundled runtimes are detected (never in a Linux build with `QGC_GST_BUNDLE_PLUGINS=OFF`). A bundled runtime also gets its own plugin registry, `gstreamer-<runtime version>-<arch>.bin` in the app's cache directory (`QStandardPaths::CacheLocation`), unless `GST_REGISTRY` or `GST_REGISTRY_1_0` is set, so it does not share the default per-user registry with the system GStreamer.
- **Linux AppImage**: the same Linux bundle detection applies to the AppImage's `usr/lib`; it is skipped when the user already set a `GST_PLUGIN_PATH*` or `GST_PLUGIN_SYSTEM_PATH*` variable. [`deploy/linux/AppRun`](../../../../deploy/linux/AppRun) only sets the library, GIO and Qt environment.
- **Environment hygiene**: Python virtualenv/conda variables are cleared for scanner stability (`PYTHONHOME`, `PYTHONPATH`, `VIRTUAL_ENV`, `CONDA_*`), and `PYTHONNOUSERSITE=1` is set.
- **Plugin search path**: The bundled plugin directory is set as `GST_PLUGIN_SYSTEM_PATH*`, which replaces GStreamer's default system and user plugin directories. `GST_PLUGIN_PATH*` is cleared, because it would only rescan the same directory.
- **Validation**: A macOS app that ships `GStreamer.framework` without its plugin directory fails initialization with a clear error. A missing or non-executable bundled `gst-plugin-scanner` only logs a warning: QGC sets `GST_REGISTRY_FORK=no`, so GStreamer scans plugins in-process.

## Platform Setup

### Linux

Use `python3 -m tools.setup.install_dependencies`, or manually:

```bash
sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev python3-gi python3-gst-1.0
```

Debug builds also use `libgstrtspserver-1.0-dev`, when present, for MockLink's RTSP test stream and the RTSP receiver tests.

Unless `QGC_GST_BUNDLE_PLUGINS` is `OFF`, installs and AppImages bundle the system's allowlisted plugins, the plugin scanner and only the GIO modules for TLS and proxies (`libgiognutls`, `libgiolibproxy`, `libgiognomeproxy`, `libdconfsettings`). The VFS modules stay out: the AppImage, and a bundle on GLib 2.76+, run GIO with `GIO_USE_VFS=local`.

`udpsrc` before gst-plugins-good 1.24.10 (Ubuntu 24.04 ships 1.24.2) corrupts the heap when a UDP stream stops right after it starts: its `unlock()` cancels a `GCancellable` that `unlock_stop()` frees on the streaming thread. `GStreamer::completeInit()` serializes the two for those versions, as the upstream fix ([gstreamer!7897](https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/7897)) does.

#### GPU bin selection

When gst-gl and DMABuf are both compiled, `qgcvideosinkbin`'s GPU branch comes in two variants. `QGC_GST_GPU_BIN`
(case-insensitive, read once per process) picks one:

- `glupload` (default): glupload → glcolorconvert → GLMemory. gst-gl imports LINEAR and vendor-tiled DMABuf, at the
  cost of its own copy/convert.
- `dmabuf`: DMABuf and DMA_DRM go straight to QGC's EGLImage importer, skipping gst-gl. It binds LINEAR buffers only,
  so it suits LINEAR producers (V4L2, Mali, V3D); a driver that exports only tiled surfaces, such as Intel iHD, can
  fail to negotiate.

An unknown value logs a warning and keeps the default. Builds with only one variant ignore the variable: Android
always uses glupload, Linux without gst-gl always imports DMABuf directly. The choice is logged once under
`Video.GStreamer.HwBuffers.Config`.

#### DMABuf zero-copy diagnostics

Linux DMABuf zero-copy normally tries the single-EGLImage importer for shared-fd
multi-plane formats such as NV12/P010, matching the common VA-API exporter
layout. To force CPU fallback or per-plane import during driver triage, disable
that importer before launching QGC:

```bash
export QGC_GST_DMABUF_SINGLE_EGLIMAGE=0
```

Unset the variable, or set it to `1`, to restore the default behavior.

QGC also switches to per-plane import on its own when the single-EGLImage
importer fails with `EGL_BAD_MATCH`. It disables DMABuf zero-copy for the
session only when per-plane import fails the same way.

`QGC_GST_DMABUF_CACHE=1` keeps each pooled buffer's EGLImages across frames
instead of importing them every frame. It is off by default because no test or
CI host exercises the direct DMABuf importer.

### macOS / Windows / iOS

GStreamer SDKs are auto-downloaded during CMake configure. To use a local installation instead, set `GStreamer_ROOT_DIR` (on iOS, a `GStreamer.xcframework` or `GStreamer.framework`, or a directory containing one).

On macOS, configure uses, in this order: `GStreamer_ROOT_DIR`; Homebrew's GStreamer when `QGC_GST_USE_HOMEBREW` is `ON`; the SDK an earlier configure downloaded; `/Library/Frameworks/GStreamer.framework`; a new download. Homebrew's GStreamer (`brew install gstreamer pkgconf`) is also used when `GStreamer_ROOT_DIR` is a Homebrew prefix (`$(brew --prefix)`) or one of its `opt/gstreamer` or `Cellar/gstreamer/<version>` directories. Its pkg-config modules, and GLib's, are read with Homebrew's own `pkg-config`, which also finds the macOS system libraries' modules; `gstreamer.version.macos` (or `QGC_GST_VERSION`) is its minimum version. Such a build only runs from the build tree: Homebrew's libraries and plugins load each other by absolute paths under its prefix, so `cmake --install` and packaging fail with an error instead of bundling them.

On iOS only the `GStreamerMobile` framework links the SDK's merged static archive; `GStreamer::GStreamer` carries just its headers, defines and system-framework links. The app takes every GStreamer function from the framework, which exports those its registered plugins use plus `QGC_GST_IOS_APP_CORE_SYMBOLS` (`platform/IOS.cmake`). A new core call that no plugin uses therefore fails the app's link with an undefined-symbol error naming it: add the symbol to that list. A post-build check (`IOSSingleCoreCheck.cmake`) fails if the app still ends up defining core symbols.

### Android

Auto-downloaded during CMake configure. No manual setup required. Each ABI build extracts only its own tree from the universal SDK tarball. Windows and macOS hosts query it with the pkg-config the SDK bundles for them (`share/gst-android/ndk-build/tools/<host>/`); Linux hosts use their own.

The plugins' Java sources and GStreamer's fonts and CA bundle are staged in the build directory (`extra_java_sources/`, `extra_assets/`), which [`android/build.gradle`](../../../../android/build.gradle) adds to the APK.

> **Windows users building for Android**: Enable Developer Mode (Settings > System > For developers) to support symbolic links during the build.

### Caching downloaded SDKs

Auto-downloaded SDK archives are cached to `${CPM_SOURCE_CACHE}/gstreamer-*`. `CPM_SOURCE_CACHE` defaults to `.cache/CPM` in the source tree; set it to a location shared by your checkouts (e.g. `~/.cache/CPM`) to download the archives only once (GStreamer 1.28.4: 0.3-1 GB per platform, e.g. ~1 GB for Android and 880 MB for Windows x64):

```bash
export CPM_SOURCE_CACHE=$HOME/.cache/CPM
```

Cached archives are verified on every hit against the checksum pinned in `.github/build-config.json`, or the upstream `.sha256sum` sidecar for an unpinned version; an auto-downloaded SDK is fetched again when its `gstreamer.version.<platform>` changes, and cached GStreamer probe results are recomputed whenever the SDK changes. Each archive is extracted once into a directory beside it that is stamped with the archive hashes, and a tree stamped with the pinned checksums is used without downloading or even keeping its archives (CI caches only the trees); configures sharing the cache take a lock instead of downloading or extracting over each other. The Windows SDK installer runs in its portable mode, so it leaves no `GSTREAMER_1_0_ROOT_*` environment variable, registry key or uninstall entry behind.

## Testing Pipelines

### Sending test video (h.264 over UDP)

```bash
gst-launch-1.0 videotestsrc pattern=ball ! video/x-raw,width=640,height=480 ! x264enc ! rtph264pay ! udpsink host=127.0.0.1 port=5600
```

### Receiving test video

```bash
gst-launch-1.0 udpsrc port=5600 caps='application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H264' ! rtpjitterbuffer ! rtph264depay ! h264parse ! avdec_h264 ! autovideosink sync=false
```

### Supported protocols

`GstSourceFactory` accepts these URI schemes:

- `rtsp://`, `rtspt://`: RTSP (the video stream only).
- `udp://`: H.264 over RTP/UDP; `udp265://`: H.265 over RTP/UDP.
- `mpegts://`: MPEG-TS over UDP; `tcp://`: MPEG-TS over TCP.

### Automated tests

| Test | Label | Covers |
| --- | --- | --- |
| `GStreamerEnvironmentTest` | Unit | The bundled runtime's private plugin registry, which keeps a user-set `GST_REGISTRY` or `GST_REGISTRY_1_0`; a system GStreamer keeps the default registry. |
| `GStreamerTest` | Integration | Helpers, the source factory, the `gstqgc` sink and the `HwBuffers` paths. |
| `GstVideoReceiverComponentsTest` | Unit | The receiver's components without a stream: reconnect backoff, stall detection, recording finalization by ancestry, pipeline and `.dot` file names. |
| `GstVideoReceiverTest` | Integration | `GstVideoReceiver` end to end against a live H.264 RTP stream it sends itself on loopback. |

Each test skips, rather than fails, when GStreamer or a plugin it needs is missing (`GstVideoReceiverTest` needs `x264enc` among others).

```bash
ctest --test-dir build -R '^(GStreamerEnvironmentTest|GStreamerTest|GstVideoReceiverComponentsTest|GstVideoReceiverTest)$' --output-on-failure
```

## GStreamer Logging

- In-app: Use QGroundControl's logging settings
- Environment variables: See <https://gstreamer.freedesktop.org/documentation/gstreamer/running.html>
- Command line options: `--gst-debug-level`, `--gst-debug`, `--gst-debug-help`, etc.
- Receiver flow (starts, stops, reconnects, the decoder chosen, pipeline latency, GPU path statistics): enable the `Video.GStreamer.GstVideoReceiver` category. Failures are logged as warnings even with it off.

## Pipeline Observability

QGC supports the standard GStreamer observability env vars. Set them before launching QGC.

### Pipeline graph dumps (`.dot` files)

When `GST_DEBUG_DUMP_DOT_DIR` is set, QGC writes each receiver's pipeline graph there at key transitions as `receiver-<stream>-<tag>.dot`. `<stream>` is the receiver's name (`videoContent`, `thermalVideo`), so the two receivers don't overwrite each other's files, and `<tag>` names the transition (`pipeline-initial`, `pipeline-started`, `pipeline-with-videosink`, `pipeline-error`, `pipeline-watchdog-timeout`, `pipeline-recording-stopped`, …). A transition's next dump overwrites its last one.

```bash
export GST_DEBUG_DUMP_DOT_DIR=/tmp/qgc-pipeline-dots
./QGroundControl
dot -Tpng /tmp/qgc-pipeline-dots/receiver-videoContent-pipeline-started.dot -o pipeline.png
```

When the env var is **unset**, QGC instead writes a snapshot to `<CacheLocation>/qgc-pipeline-dot/receiver-<stream>-<tag>-<UTC timestamp>.dot` on a pipeline `ERROR` and on a watchdog timeout, keeping the 10 newest, so field-bug-report bundles include the topology automatically. `GstVideoReceiver::dumpPipelineGraph(tag)` (`Q_INVOKABLE`, so callable from QML) writes the same snapshot on demand for use from a debug menu.

Every dump shows topology, caps, media types and states, but leaves out element properties, which can hold stream credentials (such as `rtspsrc`'s `user-id` and `user-pw`).

### Latency tracer

Per-element latency from source to sink:

```bash
GST_TRACERS="latency(flags=pipeline+element)" GST_DEBUG=GST_TRACER:7 ./QGroundControl 2> latency.log
```

Plot the resulting `element-latency` / `latency` log lines with any of the GStreamer community tools (e.g. `gst-stats`, GStreamerLatencyPlotter).

### Live pipeline visualizer (`gst-dots-viewer`, GStreamer ≥ 1.26)

```bash
GST_TRACERS=dots ./QGroundControl &
gst-dots-viewer
# open http://localhost:3000
```

The viewer streams the live pipeline graph over WebSocket and exposes a snapshot button — no `xdot` viewer needed.

### Leak / lifetime debugging

```bash
GST_TRACERS=leaks GST_DEBUG=GST_TRACER:7 ./QGroundControl
```

Logs every `GstObject` / `GstMiniObject` that wasn't released by exit. Useful when triaging pad-leak regressions in the source factory or sink bin.

---

--- METRICS ---
- Files Extracted: 17
- Estimated Token Budget: ~54706 tokens
- Canonical Reference: https://codewiki.google/github.com/mavlink/qgroundcontrol
