A photoreal drone simulator you fly over ROS 2 — Unreal Engine 5.8 + Cosys-AirSim renders the world and the sensors, PX4 v1.16 SITL flies the aircraft, and ROS 2 Jazzy is the only control interface. Bring your own Unreal world, place the vehicle in it, choose which sensors exist and how they are tuned, and fly the same ROS 2 graph you would fly on real hardware.
Everything here is SITL. The sim ↔ real boundary in this project is the transport swap,
not the commands — which is why the controller that flies here is the controller that flies on
a Pixhawk 6C.
Not a demo world with a drone bolted on. scripts/sim_up.sh --world YourProject.uproject --spawn 50,-30,-10 loads a project you authored, puts the vehicle where you asked, and hands
back a stack whose EKF origin has been verified rather than assumed. Imagery is
photorealistic on a stock upstream plugin — simGetImages matches Unreal's own render of
the same camera actor at the same transform to 1.15 of 255, across six scenes from close-up
to 70 m.
A fresh machine must reach a working stack from this repository alone — no undocumented manual steps, no "it works on
carbonite".
This is a first-class goal, not packaging polish. The stack is an assembly of pinned upstreams whose interactions are fragile, and the difference between "we got it working" and "anyone can get it working" is entirely whether the recipe is captured.
Two rules follow from it, both learned the hard way here:
- Pin what you actually built and smoke-tested — a SHA, never a branch. A tagged upstream
release in this stack became unbuildable retroactively because its superbuild pinned a
dependency by branch and that branch was deleted (
fatal: invalid reference: 2.12.x). A branch is not a pin. - Write Dockerfiles from evidence, not from documentation. Getting to a running
airsim_nodetook four undocumented discoveries, every one of them now a comment inscripts/build_airsim_wrapper.sh; three of the steps that stood up the original Gazebo baseline likewise deviated from this project's own reference docs. A Dockerfile written from the docs reproduces a broken stack.
One credential step cannot be removed from this side, and is stated up front rather than
buried: the Unreal engine base image ghcr.io/epicgames/unreal-engine is credential-gated —
it needs EpicGames GitHub org membership plus a PAT with read:packages. Everything else
builds from a clone. Backlog: docs/docker/todo.md.
PX4, the Micro-XRCE-DDS Agent, px4_msgs, Cosys-AirSim and QGroundControl are consumed as
pinned upstreams. The original work is the glue: the ROS 2 graph and its launch
composition, the bring-up ordering, the scenario/eval harness, the measurement scripts, and the
bring-your-own-world path. Vendored trees stay byte-identical to upstream — the three
patches this stack needs live in patches/cosys-airsim/ and are
applied to a container-local copy, never to vendor/.
Demonstrated, not argued: an unmodified offboard controller written against the Gazebo
baseline reached 4/4 waypoints, max error 0.79 m, reproduced three times including once
from a cold start. The controller was never patched; only the transport was swapped. PX4's
topic surface is identical either way — 51 /fmu/ topics, 24 /fmu/out, verified by diffing
rather than by inspection.
What people build on this — vision-based navigation, VLM agents, planners, perception stacks, benchmark reproduction — are applications, not the repo's purpose. The deliverable is the simulator.
Prerequisites. Docker with GPU access, an NVIDIA GPU for the renderer, and disk: the engine
image is 24.0 GB compressed / ~57 GB on disk, the PX4 image 11.0 GB, the vendored
Cosys-AirSim tree ~1.3 GB. The renderer is pinned to GPU 0 at the container boundary
(--gpus '"device=nvidia.com/gpu=0"', in sim_up.sh) because -RenderOffScreen has
historically ignored application-level GPU hints; see docs/bench.md for the
render/infer split.
gh auth token | docker login ghcr.io -u <github-user> --password-stdin # EpicGames org + read:packages
docker build -f docker/px4.Dockerfile -t drone-sim/px4:v1.16.0 .
docker build -f docker/qgc.Dockerfile -t drone-sim/qgc:v1.16.0 .
docker build -f docker/ros2.Dockerfile -t drone-sim/ros2:v1.16.0 .
docker build -f docker/unreal.Dockerfile -t drone-sim/unreal:ue5.8 .Order does not matter. Every image now builds from ubuntu:24.04 (or, for the renderer, the
Epic engine image) and none derives from another, so these can run in any order or in parallel.
They used to chain off the PX4 image, which cost qgc and video ~11 GB of PX4, ROS and NuttX
they never used (SIM-19).
The PX4 image pulls PX4 v1.16.0 plus submodules and verifies every pin against its recorded
SHA, failing the build on a mismatch; the pins land in /etc/drone-sim-versions inside the
image. Expect 20–40 minutes.
It builds the firmware toolchain and then throws it away. A
firmwarestage clones the full PX4 tree and installs the NuttX/ARM cross-compiler — sodocker build --target firmwarestill gives you an image that can flash a real Pixhawk 6C — while the image that ships copies onlybuild/px4_sitl_defaultout of it. That is the difference between 11.0 GB and 466 MB, and a stage split rather than a delete becauseapt purgein a later layer reclaims nothing.
It no longer installs Gazebo.
Tools/setup/ubuntu.sh --no-sim-toolsplus an explicit reinstall of the build dependencies that are not Gazebo (bc,libeigen3-dev,protobuf-compiler,pkg-config,libxml2-utils) — measured 11.6 GB → 11.0 GB, and the build asserts Gazebo is absent rather than trusting the flag. NuttX is still installed on purpose: real Pixhawk 6C firmware is flashed from that tree.
QGroundControl is required, not optional tooling. PX4 refuses to arm without a
ground-station datalink and NAV_DLL_ACT is left enforced deliberately — a real Pixhawk
refuses too, so relaxing it in simulation would hide a real-flight failure. QGC is baked into
the image, pinned and SHA256-verified at build time, so a checksum mismatch fails the build
instead of surfacing later as a vehicle that will not arm.
The ROS 2 image is the companion-computer side: ROS 2 Jazzy plus the wrapper's build
dependencies (ros-jazzy-geographic-msgs, ros-jazzy-mavros-msgs, python3-msgpack, patch)
and docker/ros-profile.sh at /etc/profile.d/10-ros.sh. It deliberately carries no
ros-gz-bridge — /clock now comes from the simulator, remapped in perception.launch.py,
so the bridge has no consumer.
vcs import vendor < .repos # ~1.3 GB — Cosys-AirSim at the pinned SHA, not a branch
docker run --rm -v "$PWD/vendor/Cosys-AirSim:/src" drone-sim/unreal:ue5.8 \
bash -lc './build.sh --ue-root /home/ue4/UnrealEngine'
--ue-rootis mandatory, not advisory. The engine image ships no system clang — the compiler is the engine's bundledv26_clang-20.1.8-rockylinux8, and a build without--ue-roothas no compatible compiler at all rather than a graceful fallback. Verified on the artifact rather than on the build script's own banner:readelf -p .comment libAirLib.areadsclang version 20.1.8. Detail:docs/worklog/2026-08-01-c02-ue58-engine-image.md.
To fly a world of your own, inject the plugin into it once with
scripts/inject_airsim.py /path/to/YourProject.uproject — pure text edits plus a folder copy,
no editor, no GUI, no display.
./scripts/sim_up.shFour containers, brought up in the only order that works. It waits for the vehicle to
settle, then verifies the EKF origin before declaring the stack usable, printing
stack up and origin verified -- safe to fly in roughly 80 s.
Each container is one machine that will exist when this flies for real. That is the whole rule, and it is worth stating because the obvious alternatives are both wrong: one container is not enough, and a container per process is too many.
| Container | Image | Real counterpart |
|---|---|---|
sim-unreal |
drone-sim/unreal:ue5.8 |
none — it is the world. Also the namespace donor (see below) |
sim-px4 |
drone-sim/px4:v1.16.0 |
the Pixhawk 6C — PX4 firmware, on its own board |
sim-ros2 |
drone-sim/ros2:v1.16.0 |
the Jetson Orin NX — the uXRCE-DDS bridge and this repo's reference nodes. Not where your code goes — see below |
sim-qgc |
drone-sim/qgc:v1.16.0 |
the ground station — the only MAVLink-over-IP client |
Why PX4 is separate. On the aircraft PX4 runs on dedicated flight-controller hardware and reaches the companion computer over a UART. That boundary is the entire sim-to-real claim: sim and real differ only by the transport across it. Folding PX4 into the companion container would erase in the layout the one line the design rests on.
Why the agent is not separate. The uXRCE-DDS agent is plumbing, not a service — it is
what makes /fmu/* appear, and nothing connects to it directly; your code talks ROS 2. On the
Jetson it is simply a process beside your nodes. It had its own container until 2026-08-04, and
that modelled a boundary which does not exist anywhere in the real system. It now runs inside
sim-ros2 under a supervising loop that restarts it and prefixes its output [xrce], which is
more than it had before: this stack has never carried a restart policy on anything.
Never health-check the agent by looking for its process. Two independent traps, both measured here:
MicroXRCEAgentexits 0 when it fails to bind, so "it started and returned success" is compatible with no bridge at all.pgrep -f MicroXRCEAgentmatches the supervising loop as well as the agent — the loop's own command line contains the string. It reports a hit whether or not the agent is alive.What proves the bridge is up is the bring-up's
wait_for_fmu: real/fmu/outtopics carrying a finite EKF origin. Assert on the data, never on the process.
The simulator does not host your application. sim-ros2 runs the uXRCE-DDS bridge and this
repo's reference nodes — interfaces, control, bringup — which exist to prove the graph
works, not to be where you build. Your autonomy code stays yours: your image, your workspace,
your branch. It attaches to the running graph from outside:
./scripts/attach.sh --image my/autonomy:latest ros2 run my_pkg my_node
./scripts/attach.sh # or just an interactive shell, ROS 2 already sourcedA native ros2 install needs no configuration at all. A process in its own namespaces
already receives the whole graph — Fast-DDS falls back to UDP on its own. No DDS profile, no
published ports, no flags. All it needs is px4_msgs (below).
The one combination that does NOT work is sharing the network namespace alone — and it is worse than sharing nothing. Measured against a live stack, same subscriber, three ways:
attach messages received no namespaces shared (fully separate) 3 ✓ --network container:sim-unreal0 ✗ --network …and--ipc container:sim-unreal3 ✓ Sharing the netns makes Fast-DDS see the peer as same-host, so it picks the shared-memory transport — but
/dev/shmis still your own, and nothing is delivered.ros2 topic listshows all 51 topics throughout. Share both, or share neither.attach.shshares both.
Use attach.sh when you want the shared-memory path (large image topics) or a shell with the
stack's environment ready; run natively when you would rather not containerise.
Two things your image needs: px4_msgs built from the same branch as the firmware
(release/1.16 — base on drone-sim/ros2:v1.16.0 and you get it), and BEST_EFFORT +
TRANSIENT_LOCAL QoS on /fmu/out/*, because a default RELIABLE subscription matches
nothing and reads as silence. Both are in docs/conventions.md.
Why QGroundControl is a container and not an afterthought. PX4 refuses to arm without a GCS
datalink (NAV_DLL_ACT=2), and that check is deliberately left enforced because a real
Pixhawk enforces it. Stop sim-qgc and arming is denied — verified in both directions. It is a
functional dependency of flight, not a viewer.
The one boundary that is admittedly wrong. sim-unreal donates the network and IPC
namespaces every other container joins, so the renderer — the one component with no hardware
analogue — is what the whole stack structurally depends on. That inverts reality, and it means
the split does not buy isolation: it is one machine wearing four hats. Tracked as D-06 in
docs/docker/todo.md; the namespace sharing is load-bearing today
because Fast-DDS delivers over shared memory (see Network and ports below).
Why it verifies rather than just waits. PX4 sets its EKF local origin once. If it initialises before the simulated vehicle has settled onto geometry,
ref_altfreezes at the wrong height and every altitude PX4 reports is silently offset for the rest of the session — measured once at 35.167 m, i.e. the vehicle "was" 35 m up while sitting on the ground, the controller commanded a descent, nothing moved, and every symptom pointed at flight code that was fine. That cost a day. If the origin is stale the script restarts PX4 and re-checks; a stack it cannot repair is refused, andrun_gate.pyscores such runs VOID, never FAIL — they never measured the flight code.
| flag | what it does |
|---|---|
--world PATH.uproject |
load your own Unreal world instead of the bundled Blocks environment |
--settings PATH.json |
your own settings.json — which sensors are active and how they are tuned |
--spawn X,Y,Z[,YAW] |
where to put the vehicle, in metres NED |
--vehicle NAME |
required only if your settings define several vehicles |
--allow-below-origin |
permit a positive Z (i.e. genuinely below the origin) |
A world usually needs converting first. A project that ships its own
Source/must be compiled against UE5.8, and a World Partition level needspatches/cosys-airsim/0005or the drone falls through it forever../scripts/convert_world.sh <your.uproject> --map /Game/Maps/Xdoes both — seedocs/worlds.md.
Each has an environment equivalent: WORLD, SETTINGS_FILE, SPAWN, SPAWN_VEHICLE,
SPAWN_ALLOW_BELOW. Z is NED — negative is UP; Z=10 puts the drone 10 m underground,
which is why the script refuses a positive Z without the opt-in. The committed
sim/ue5/settings.json is never modified: a run-time copy is written beside it.
docker exec -d sim-ros2 bash -lc 'ros2 launch bringup perception.launch.py'The wrapper is in the image; the node still has to be started. (
SIM-37, 2026-08-19.) It used to be built into the container by./scripts/build_airsim_wrapper.shafter everysim_up.sh— that step is gone, and the builder now refuses on a stack that already has a wrapper, because it would delete the image's copy first.Nothing starts
airsim_nodefor you. Until you run the launch above,/fmu/*works and every/airsim_node/*topic is simply absent.Use
bash -lc.docker execbypasses the image entrypoint, so a plaindocker exec sim-ros2 ros2 topic listruns without a ROS environment and reports 0 topics on a perfectly healthy stack. The login shell picks up/etc/profile.d/10-ros.sh.
./scripts/run_park_tour.sh # Blocks — the known-good control
./scripts/run_park_tour.sh --world /path/CityPark.uproject \
--spawn 50,-30,-10 --mode circle --radius 25 --altitude 8This is the end-to-end example: it brings the stack up itself (steps 3 and 4), starts the bag before the mission node and stops it after, flies a closed circuit using only the ROS 2 interface — no RPC, no MAVLink — then lands and reports a verdict.
out/park-tour-<UTC>/
park-tour_0.mcap every /fmu/out/*, /airsim_node/*, /tf and /clock for the whole run
metadata.yaml ros2 bag's own
summary.json waypoints, per-leg error, verdict
mission.log the node's stdout
stack.log bring-up, for when a run dies before it flies
--mode circle streams a continuously moving setpoint with velocity feed-forward, so PX4 tracks
a smooth arc instead of braking at every corner. scripts/render_run_video.py and
scripts/plot_run_path.py derive an mp4 and a ground-track plot from the bag, so the picture
and the verdict come from the same evidence and cannot drift apart.
docker cp scripts/verify_sensors.py sim-ros2:/tmp/verify.py
docker exec sim-ros2 bash -lc '
source /opt/ros/jazzy/setup.bash
source /airsim_root/ros2/install/setup.bash
source /ros2_ws/install/setup.bash
python3 /tmp/verify.py'Every failure this project has hit here looked healthy from the outside: topics listed while
publishing nothing because the subscriber QoS did not match, an IMU at 1501 Hz of which 78% were
the same sample republished, a camera_info whose frame_id no TF-aware node could resolve, a
stale origin reporting 35 m of rock-steady altitude with z_valid: true. So the check asserts
values — an all-black camera and a working one both publish an image, and only one has pixel
variance.
By default sim_up.sh publishes nothing to the host. The renderer owns the network namespace
and every other container joins it with --network container: and --ipc container: — sharing
the network namespace alone gets you topic names in ros2 topic list and silence from
ros2 topic echo, because Fast-DDS discovers over UDP but delivers over shared memory.
Inside that namespace: 4560/tcp simulator ↔ PX4 MAVLink, 8888/udp uXRCE-DDS agent, 18570/udp the GCS datalink, 14540/udp offboard.
18570, not 14550. PX4 SITL's GCS MAVLink instance binds
18570+instance. A heartbeat aimed at 14550 is discarded silently, with no error and no log line, and the datalink simply never comes up — verified by binding 14550 and receiving nothing while PX4 already held 18570.
MAVLink has no authentication. Nothing above is reachable from outside the namespace today, and that is the safe default. If you publish any of these ports onto a host interface, anyone routable can arm and command the vehicle — do that on a trusted network only, and note that the same setting later points at a real Pixhawk.
Your autonomy computer is usually not this machine — it is a Jetson on the bench or a box across the LAN. Two switches cover that, and both are off by default: the stack publishes nothing and is reachable only from the host it runs on, because host mode exposes unauthenticated MAVLink and that is a decision to make per network, not a default to inherit.
Which switch you need is decided by one question: does the path between the two machines carry
UDP multicast? DDS discovers over multicast (239.255.0.1:7400) by default, and a VPN or a routed
subnet almost never forwards it.
| Path between the machines | Switch | What the peer gets |
|---|---|---|
| Same host | (default) NET_MODE=shared |
private namespace, nothing published |
| LAN that forwards multicast | NET_MODE=host |
the whole graph, no DDS config |
| VPN / routed subnet — no multicast | NET_MODE=host + DISCOVERY_SERVER=<ip>:<port> |
the whole graph over plain unicast |
# LAN
NET_MODE=host ./scripts/sim_up.sh
# VPN: start a discovery server anywhere BOTH machines can reach, BEFORE the stack.
# `fastdds` ships in drone-sim/ros2, so you do not need ROS 2 installed on the host:
docker run -d --name sim-ds --network host --entrypoint bash drone-sim/ros2:v1.16.0 \
-lc 'fastdds discovery -i 0 -l 0.0.0.0 -p 11811'
NET_MODE=host DISCOVERY_SERVER=127.0.0.1:11811 ./scripts/sim_up.shThe server must be up before the stack, and it is not managed by sim_up.sh — teardown
does not remove it, so it survives a re-run (which is what you want) and you stop it yourself
with docker rm -f sim-ds.
DISCOVERY_SERVER changes only how peers find each other — it implies no network mode. You
still need NET_MODE=host for the stack to advertise a routable address rather than the
docker-bridge 172.17.0.2 that only this machine can reach.
On the subscriber, point at the same server and use the UDP-only profile:
export ROS_DISCOVERY_SERVER=<server-ip>:11811
export ROS_SUPER_CLIENT=true
export FASTRTPS_DEFAULT_PROFILES_FILE=/path/to/configs/dds/udp-only.xmlMeasured from a second host — a separate machine on the overlay, sharing no namespaces with the stack and reachable only over a routed (relayed) link that carries no multicast. Each run is paired with the same probe run without the server, which is what makes it evidence rather than a coincidence:
with discovery server control: multicast only
topics visible total=53 fmu=51 (×3) total=2 fmu=0 (×3)
/fmu/out delivery pos=1936 imu=1936 pos=0 imu=0
ref_alt 123.282 m — matches the stack's verified EKF origin
fmu=51 is exactly what the stack sees locally. The control's total=2 is the probe's own
/parameter_events and /rosout — i.e. a correctly isolated node that found nothing. So the
server is unambiguously what carried the graph.
These are discovery and delivery numbers, not throughput numbers. The link measured was a NetBird relayed path (~27 ms RTT, MTU 1280), so bandwidth and latency figures from it would describe the relay rather than your network. Large samples fragment at that MTU.
ROS_SUPER_CLIENT=trueis not optional, and omitting it looks like a total failure. A plain discovery client is only told about participants it has already matched.ros2 topic echohas to resolve the message type from the graph before it can subscribe, so as a plain client it fails withCould not determine the type for the passed topic— with a healthy publisher sitting right there.sim_up.shsets it for you; set it on your side too.
ros2 node listreturns 0 for/fmu/*— in every mode, including plain multicast. The uXRCE-DDS agent creates raw DDS participants without ROS 2 node metadata. Topics and data are fine; only the node listing is empty. Do not read it as a broken link.
The server is a rendezvous, not a relay. Data still flows peer-to-peer, so the two machines need direct routable reachability — it removes the multicast requirement, not the routing one.
Host mode puts PX4's unauthenticated MAVLink ports on every interface this machine has, including the VPN. Use it on a network you trust.
| Capability | State | Evidence |
|---|---|---|
| Flies over ROS 2 | ✅ | 4/4 waypoints, max error 0.79 m, reproduced 3× including a cold start |
/fmu/* parity with real hardware |
✅ | 51 /fmu/ topics, 24 /fmu/out — diffed identical against the retired Gazebo baseline the controller came from |
| Sensors in the ROS 2 graph | ✅ | RGB, depth, GPU-LiDAR, IMU, GPS, magnetometer, odometry — all pass value-based checks |
| Photorealistic imagery | ✅ | matches Unreal's own render to 1.15 of 255 across six scenes, on a stock plugin |
| Bring your own world + deliberate spawn | ✅ | --world / --spawn, with a ground probe for unknown terrain |
| Deterministic bring-up | ✅ | origin verified and repaired, or the run is refused — and now 10 cold starts in a row with zero VOID, which is what that caveat was waiting for |
| Recorded example mission | ✅ | scripts/run_park_tour.sh — MCAP, summary.json, video, ground track |
| Flight gate | ✅ | 10/10, 100%, zero VOID over independent seeded cold starts — 0.775–0.805 m worst error, 193–195 s per seed. VOID is excluded from the rate and blocks the criterion |
| Dynamic actors in the world | 📋 | the RPC surface (simSpawnObject, simSetObjectPose) is known live in this build; nothing spawned yet — and it needs no project C++ and no plugin change |
| Wind / environment control | 📋 | needs Cosys-AirSim's own wind API |
Measured sensor throughput. Rates are capped by perception.launch.py, not by the
hardware — imagery at 20 Hz, LiDAR at 10 Hz — and measured throughput sits at 94% and
100% of those ceilings. The per-topic table, with types and measured rates, is in
docs/quickstart.md.
- Lockstep is dead code in Cosys-AirSim:
initialize()sets the flag andopenAllConnections()clears it twice, so"LockStep": trueis silently ineffective. Every timing number here is free-running — never quote an RTF from this stack as deterministic. - A seed controls the spawn pose and nothing else. The retired Gazebo harness varied wind and vehicle mass through a generated world overlay; there is no equivalent yet. Ten seeded runs are closer to ten repeats — still worth running, since flaky failures surface under repetition, but do not describe a gate run as covering varied conditions.
- Runs are not bit-reproducible. Two back-to-back runs with identical config gave waypoint
errors
[0.225, 0.104, 0.154, 0.204]and[0.118, 0.076, 0.158, 0.187]. A failing seed cannot be replayed — which is exactly why every run keeps its MCAP. - Frames are NWU, not ENU, despite the upstream documentation.
- Video capture is latency-bound, not bandwidth-bound. Every route funnels through a
blocking GPU→CPU readback: 14.1 Hz at 960×540, 10.3 Hz at 1920×1080 — ~71 ms fixed plus
~5 ms/MB. GPU encode would sidestep it, but NVENC cannot open a session on driver
610.43.03, and Isaac Sim is deferred on the same driver: two capabilities, one host-side
decision. See
docs/nvenc-driver-blocker.md. - One simulator segfault, n=1, after ~57 minutes of continuous running. A deliberate 90-minute soak of the full stack ran 74,253 captures with zero anomalies and refuted both standing hypotheses. Not reproduced is not fixed — treat it as a rare, uncharacterised event, not a known ceiling.
versions.lock every pin, its status, and how it was verified — the authority
.repos vcstool manifest for the vendored upstream trees
docker/ one Dockerfile per image — unreal, px4, ros2, qgc, video,
airsim-client — plus the two entrypoints and the ROS profile
scripts/sim_up.sh the stack, in the only order that works
scripts/ wrapper build, flight gate, scenario runner, the example mission,
and the measurement harnesses that produced the numbers above
ros2_ws/src/ the glue: interfaces (mission contracts), bringup (launch
composition), control (offboard + the park tour); perception,
state_estimation, planning and evaluation are placeholders
sim/ue5/ settings.json — which sensors exist and how they are tuned,
plus worked examples
scenarios/ seeded mission definitions the gate runs
patches/cosys-airsim/ three upstream defects, applied to a container-local copy only
tests/ off-target tests — the tier-1 CI suite
vendor/ pinned upstream checkouts (git-ignored; see .repos)
out/ run artifacts — MCAP, summary.json, video (git-ignored)
docs/ quickstart, the backlog, graph conventions, bench briefing,
worklogs, and the retired stacks under history/
| Doc | What it is |
|---|---|
docs/quickstart.md (HTML) |
Run it — launch, world selection, sensor selection and tuning, the topic/type/rate table, and the ROS 2 command interface |
docs/todo.md |
The backlog — every SIM-NN with its acceptance criterion and its evidence. The one cross-cutting area keeps its own file: docs/docker/todo.md |
docs/roadmap.html |
Where the simulator is and which capability comes next |
docs/architecture.html |
What runs and what moves — the four containers, what each holds, and the three transports between them, with the one that is actually the control interface marked |
docs/gpu-in-docker.md |
How the GPU reaches the container — CDI, the Vulkan ICD path trap, and six commands that prove hardware rendering rather than a software fallback |
docs/worlds.md (HTML) |
Bring your own world — converting a third-party Unreal project: injection, the UE5.8 build fixes, World Partition, and how to tell a converted world actually flies |
docs/conventions.md |
The frozen ROS 2 graph — these names reach the aircraft unchanged |
versions.lock |
Every pin, its status, and how it was verified |
docs/bench.md |
The machine and container this runs on, and the GPU split |
docs/nvenc-driver-blocker.md |
Why GPU video encode is unreachable on this driver |
docs/worklog/ |
Dated record of each investigation, with the evidence and the dead ends |
docs/history/ |
Retired backlogs and design docs — the Gazebo baseline, Isaac Sim, and the original research plan |
./scripts/run_local_ci.sh # fast checks, ~30 s
./scripts/run_local_ci.sh --gate # + the seeded flight gateThe fast checks are the same ones GitHub Actions runs on every push, so a local pass means the
same thing: off-target tests, shell and Python parse checks, every drone-sim/… image
reference names an image declared in versions.lock (scripts/check_image_refs.py), .repos
agrees with the lock, every worklog has an HTML render and an index card, the attribution sweep
over every tracked file (scripts/check_attribution.sh), and every versions.lock CONFLICT is
documented.
The flight gate is not automated. It cannot run on a hosted runner — it needs a GPU and tens of gigabytes of images, and the retired Gazebo gate already missed that budget without one (12.6 GB image, 2 vCPU against an RTF floor of 0.95) — and a self-hosted runner on a public repo would let fork pull requests execute on the workstation. Running it here is accepted as having run it.
Skipping --gate is fine for docs or tooling. It is not fine for the controller, the
scenario runner, the bring-up ordering or the gate itself — nothing else in this repo would
catch a regression there.
A clean build proves nothing about flight. Behaviour is verified by running it in the simulator — headless, on a seeded scenario, with the evidence recorded (MCAP bag, metric table, measured latency) — and a success rate over N runs, never a single green pass. If a change cannot be verified that way, say so and name the blocker.