High-performance ballistic trajectory solver with RK4, Euler, Velocity Verlet, and adaptive Cash-Karp / Dormand-Prince / Tsitouras RK45 integration, Ridder's method for zero-finding, PCHIP drag curves, Coriolis, and spin drift.
BCLIBC_integrateDormandPrince and BCLIBC_integrateTsitouras provide two
structurally-identical 7-stage FSAL 5(4) pairs with SciPy RK45-style component
scaling and adaptive controller behavior. Cash--Karp keeps its own
compatibility controller. All three use the compile-time embedded-RK core.
bclibc is the shared C++/C99 physics core behind the Ballistics Lab ecosystem —
one engine, bound natively into py-ballisticcalc (Python/Cython),
js-ballistics (TypeScript, on the bare WASM module), dart-bclibc
(Dart FFI natively, and the bare WASM module on web — see WASM build), and
micropython-bclibc (MCUs via the bundled tiny_bclibc C99 subset).
bclibc has no test suite or coverage tooling of its own. Its C++ engine is exercised and
correctness-tested through the Cython wrapper in py_ballisticcalc.exts,
part of the py-ballisticcalc Python library — its pytest suite
(euler / rk4 / rk45) drives every bclibc and tiny_bclibc code path
end-to-end via the Cython bindings. The badge above reflects that project's overall
coverage; line-level coverage of the compiled .pyx/C++ layer itself is not tracked separately.
| Directory | Description |
|---|---|
src/ / include/ |
C++ engine + libbclibc_ffi — Dart/Flutter, Python, Rust FFI |
tiny_bclibc/ |
Pure C99 engine — header-only, embeddable on MCUs |
| micropython-bclibc | MicroPython module — natmod (.mpy), usermod (baked-in firmware), FFI |
Warning
tiny_bclibc is an experimental feature. The API, CMake interface, and binary layout
may change without notice. Validate thoroughly before using in production.
tiny_bclibc/ is a pure C99 reimplementation of the ballistic engine.
Header-only by default (static inline); can also be compiled as a shared or static library
from a single TU (src/tiny_bclibc_impl.c).
Features: Tsitouras 5(4) adaptive RK45 (tiny_bclibc_integrate/_stream/_raw), fixed-step RK4
(used internally for zero-angle/apex finding), PCHIP drag, CIPM-2007 atmosphere, Coriolis, spin
drift, Ridder zero-finding, float or double precision, bare-metal / RTOS compatible (no TLS,
no heap required).
add_subdirectory(tiny_bclibc)
target_link_libraries(my_target PRIVATE tiny_bclibc::headers)See tiny_bclibc/README.md for full API and CMake options.
Warning
The MicroPython module is an experimental feature. Build system, binary format, and Python API may change without notice in future releases.
The MicroPython module is maintained in a separate repository: github.com/ballistics-lab/micropython-bclibc
Three integration modes are available:
| Mode | When to use |
|---|---|
natmod (.mpy) |
Deploy .mpy to device filesystem; works on any mpy_ld.py-supported arch |
| usermod (baked-in) | Own the firmware build; module is always available as a built-in |
FFI (libtiny_bclibc.so) |
Any unix port arch without native module support (aarch64, mipsel, …) |
See the micropython-bclibc README for build instructions, target matrix, API reference, and CI setup.
The src/ / include/ tree contains the original C++ engine with a stable C FFI layer
(libbclibc_ffi.so / .dll) for use from Dart/Flutter, Python, Rust, and any language
with C bindings.
Three embedded RK45 methods share one compile-time core (bclibc/embedded_rk45.hpp,
embedded_rk45_detail::run<Tableau, Controller>), each selectable via
BCLIBC_BaseEngine::integrate_func like any other integrator: Cash-Karp, Dormand-Prince,
and Tsitouras. All three retry a rejected attempt rather than accepting it, and grow/shrink
dt between base_step/64 and base_step*64.
Each method also has a stateful integrator class (BCLIBC_CashKarpIntegrator,
BCLIBC_DormandPrinceIntegrator, BCLIBC_TsitourasIntegrator) that owns its own tolerances and
accepted/rejected step counts per instance — assign one (wrapped in std::ref if you want to
read its stats back afterward) to a specific engine's integrate_func instead of calling a
free function that mutated shared thread-local state, which meant every BCLIBC_BaseEngine on a
thread using the same method shared one tolerance/stats, even engines integrating concurrently
with intentionally different settings. The plain free functions
(BCLIBC_integrateCashKarp and friends) remain for the common case of just wanting that method
at its default 1e-6/1e-6 tolerances with no need to read back stats. Each class also takes
its tolerances directly in its constructor (e.g. BCLIBC_CashKarpIntegrator(1e-8)), and stores
tolerances/stats in std::atomics, so one instance can safely be shared (via std::ref) across
threads — set_relative_tolerance()/set_absolute_tolerance() from one thread cannot race with
a concurrent operator() or get_stats() call from another (verified under ThreadSanitizer).
Unlike BCLIBC_integrateRK4, which freezes the drag coefficient once per step, every adaptive
method recomputes both the drag coefficient and the atmosphere sample fresh at each stage: an
earlier variant that reused RK4's once-per-step freeze produced real, tolerance-independent
accuracy failures, because the embedded error estimator is blind to model error from a stale
drag/atmosphere sample once the step grows tens of times past the fixed-step size.
Adaptive steps are also far sparser and less uniformly spaced than fixed-step RK4's output,
which broke the original per-raw-point, 3-point finite-difference (PCHIP) event/row
interpolation: it estimates slopes from neighboring raw-point spacing, which is accurate when
points are dense and uniform but measurably wrong once they are not. The fix, now used by every
integrator (BCLIBC_TrajectoryDataFilter::handle_step in src/traj_filter.cpp): integrators
stream each accepted (start, end) interval to the handler via handle_step, and RANGE/time-step
rows and APEX/MACH/ZERO event roots are reconstructed with a 2-point cubic Hermite built from
each interval's exact endpoint positions and velocities (not a finite-difference estimate),
solved by bisection where needed. Scheduled samples and physical events are kept as independent
records rather than merged when their timestamps happen to land close together — merging them
depended on raw-sample spacing that adaptive stepping no longer guarantees.
BCLIBC_integrateCashKarp (bclibc/cash_karp.hpp) is Numerical Recipes' rkck, a 6-stage,
non-FSAL pair with its own asymmetric grow/shrink controller (safety 0.9, growth capped at
5x, shrink exponent -0.25 vs growth exponent -0.20) rather than the SciPy-style one below —
preserved exactly from the pre-refactor standalone implementation. It never limits its step at a
wind-zone boundary (historical behavior, kept for output-compatibility).
BCLIBC_CashKarpIntegrator::set_relative_tolerance() / set_absolute_tolerance() each default to
1e-6; the scalar atol and rtol scale every one of the three position and three velocity
components as atol + rtol * max(abs(y), abs(y_new)), and their errors use an RMS norm —
typically 2-6x fewer total steps than fixed-step RK4 for the same accuracy.
BCLIBC_integrateDormandPrince (bclibc/dormand_prince.hpp) is DOPRI5 — the same tableau
scipy.integrate's RK45 uses — a 7-stage FSAL (First-Same-As-Last) pair: its 7th stage's A-row
equals the 5th-order solution weights, so it doubles as the accepted state and seeds the next
step's first stage, both saving a derivative evaluation and improving accuracy at wind-zone
transitions over a non-FSAL method. Its ScipyRKController matches
scipy.integrate._ivp.rk.RungeKutta's own controller exactly: exponent -1/5, safety 0.9,
factor clamped to [0.2, 10], growth capped at 1x for one step immediately following a
rejection — and it limits dt so a step never overshoots the next wind-zone boundary (needed so
every stage's wind sample stays valid for a step that starts before and would otherwise end past
a wind-zone change). Same tolerance API shape as Cash-Karp:
BCLIBC_DormandPrinceIntegrator::set_relative_tolerance() / set_absolute_tolerance(),
default 1e-6 each.
BCLIBC_integrateTsitouras (bclibc/tsitouras.hpp) is Tsit5 (Tsitouras, 2011 — "Runge-Kutta
pairs of order 5(4) satisfying only the first column simplifying assumption", Computers &
Mathematics with Applications 62(2), 770-775; coefficients verified against
ARKODE_TSITOURAS_7_4_5 in SUNDIALS/ARKODE). It is Dormand-Prince's structural twin — same
7-stage FSAL shape, same ScipyRKController (see above), same 1e-6 default tolerances via
BCLIBC_TsitourasIntegrator::set_relative_tolerance() / set_absolute_tolerance() — but with
smaller leading error-term coefficients at each order, and is tiny_bclibc's current default
adaptive core (see tiny_bclibc's README).
Measured, not assumed: across a small sweep of shot profiles at rtol=atol=1e-6, Tsitouras'
accepted+rejected step count comes out within 1-2 steps of Cash-Karp's and Dormand-Prince's on
smooth, well-conditioned ballistic trajectories — i.e. no consistent step-count or wall-clock win
over the other two for this problem class, despite the smaller leading error term. It was added
as a well-regarded, actively-used modern default elsewhere (e.g. Tsit5 in Julia's
OrdinaryDiffEq.jl/SciML) and a structurally-compatible Dormand-Prince alternative, not because it
measurably outperforms the existing methods here — don't assume it will win on your own workload
either without measuring get_step_stats() (or your language binding's equivalent) yourself.
See CHANGELOG.md for the specific fixes (per-stage recompute, streaming handler contract,
exact-derivative Hermite reconstruction).
Two build artifacts:
| Artifact | Type | Purpose |
|---|---|---|
libbclibc_core.a / bclibc_core.lib |
Static | Pure C++ engine logic. Use for C++ projects and unit tests. |
libbclibc_ffi.so / libbclibc_ffi.dylib / bclibc_ffi.dll |
Shared | Stable C API (BCLIBCFFI_*). Use for FFI bindings. |
- CMake 3.13+
- C++17 compiler: GCC, Clang, or MSVC
- Make (Linux/macOS) or Visual Studio 2022 (Windows)
./build.shOr via Make:
make # Build everything (Core + FFI)
make core # Static core only
make ffi # Shared FFI only
make wasm WASI_SDK_PATH=... # Bare WebAssembly module with exceptions (see WASM build)
make wasm-zig # ... or the small one with zig, where a throw is a trap
make clean # Remove build/PowerShell:
.\build.ps1 # Release (default)
.\build.ps1 -Configuration DebugCMD:
build.bat Release
build.bat Debugcmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build buildWindows:
cmake -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release| Platform | DLL/SO | Static |
|---|---|---|
| Linux | build/libbclibc_ffi.so |
build/libbclibc_core.a |
| macOS | build/libbclibc_ffi.dylib |
build/libbclibc_core.a |
| Windows | build/bin/Release/bclibc_ffi.dll |
build/lib/Release/bclibc_core.lib |
The BCLIBCFFI_* C ABI (bclibc_ffi.cpp) as one WebAssembly module that imports nothing — no Emscripten, no
embind — built with CMake and either of two toolchains. The core never throws (every fallible call returns a
BCLIBC_Result, checked with has_error()/is_ok()), so both toolchains build the same exception-free module, and
a failed solve returns the same BCLIBCFFI_ERR_* code as the native library on either one:
| toolchain | -DCMAKE_TOOLCHAIN_FILE= |
size |
|---|---|---|
wasi-sdk (34 tested; -DWASI_SDK_PATH= or $WASI_SDK_PATH) |
cmake/wasi-sdk-wasm32.cmake |
~1.0 MB |
zig (zig on PATH, -DZIG=, or uv run --with ziglang) |
cmake/zig-wasm32-wasi.cmake |
~85 KB (-Oz -flto) |
cmake -S . -B build/wasm -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/wasi-sdk-wasm32.cmake -DWASI_SDK_PATH=/opt/wasi-sdk-34.0 -DBCLIBC_WASM_BARE=ON
cmake --build build/wasm # -> build/wasm/bclibc_wasm.wasm; the build fails if it imports from WASIOr make wasm WASI_SDK_PATH=/opt/wasi-sdk-34.0 (wasi-sdk, build/wasm/) and make wasm-zig (zig, build/wasm-zig/).
tests/wasm_parity/parity.py runs the same shots through the native library and the module (wasmhost, on wasmtime and Node) and
fails on any difference beyond 1 ulp in the angle fields, or on a failed solve that does not return the same
BCLIBCFFI_ERR_* code as the native library; the WASM (bare module) workflow builds both flavours and runs it.
It runs with an empty import object in any host that has WebAssembly: Node, browsers, JavaScriptCore, wasmtime, wasm3,
or Python through wasmhost. The module exports the flat
BCLIBCFFI_* functions directly plus BCLIBCFFI_get_layout(), which returns every BCLIBCFFI_Shot-family struct's
field byte offsets/sizes, computed via offsetof()/sizeof() by whichever compiler built the module — callers
marshal structs into wasm linear memory using those offsets instead of hardcoding them (see dart-bclibc's
lib/ffi/bclibc_ffi_web.dart for a complete dart:js_interop binding built this way). What a host has to know,
since there is no Emscripten glue to do it:
- Call
_initialize()once before the first call (it is a reactor: static initializers). - The memory is the module's own (exported as
memory, 17 pages to start, grows on its own up to 2 GiB,BCLIBC_WASM_MAX_MEMORY; the shadow stack is 1 MiB,BCLIBC_WASM_STACK_SIZE). Nothing is passed in. After any call that may allocate, takememory.bufferagain: a grown memory detaches the oldArrayBuffer. mallocandfreeare exported: put arguments into the module's memory and free what a call hands back (BCLIBCFFI_free_trajectoryfor the records).- Pointers and
size_tare 4 bytes, so read struct fields at the offsetsBCLIBCFFI_get_layout()reports. src/wasm/bare_runtime.cppis what keeps WASI out: libc++'s abort as a trap and, for wasi-sdk specifically, inert stand-ins for the parts of its libc that pull in stdio/the clock/the stack-protector seed (zig's own libc already provides those itself). The module is built with-fno-exceptions -fno-rttiand bclibc never throws, so no exception runtime is linked in. A newer toolchain version may find another way to pull in WASI; the post-build check (cmake/check_no_imports.cmake) fails on an import section of any kind.
Numerically it matches the native library: the same inputs through libbclibc_ffi.so (x86-64, glibc) and the
module (both builds), on wasmtime, wasm3, JavaScriptCore and Node, for the six integration methods, find_zero_*, find_apex,
find_max_range and integrate_at, with sea-level, high-altitude and vacuum atmospheres, with and without Coriolis, cant
and look angle: 2098 values compared, all bit-identical except 71 that differ by 1 ulp, and only in
drop_angle_rad, windage_angle_rad and angle_rad, the ones computed with atan/atan2 (libm differs between
glibc and the module's musl; + - * / sqrt are exact everywhere). 1 ulp is the spacing between two neighbouring
doubles, about 2.2e-16 relative: for a 0.01 rad angle 1.7e-18 rad, some 10^12 times finer than the solver's own
zero-finding accuracy. So compare results across platforms with a tolerance (say relative 1e-12), not with ==. The
engines agree with each other exactly; on arm64 (FMA) it has not been measured.
The zig-built module also runs, with the same results, on the older but still maintained runtimes it was checked on:
wasmtime 24, 36 and 49; wasm3 (pywasm3); Node 18, 20, 22 and 24; bun 1.1 and 1.4; Deno 1.46 and 2.9; and Chrome 113,
120, 132 and 141. It needs sign-extension, saturating float-to-int, bulk memory and reference types (the encoding of
call_indirect) and nothing newer: no SIMD, multi-value, tail calls or exception handling, so the floor is roughly
Chrome 96+, Firefox 79+, Safari 15+ and Node 18+ (Firefox and Safari were not run here).
The public C API is declared in include/bclibc/ffi/bclibc_ffi.h. All symbols are prefixed with BCLIBCFFI_.
BCLIBCFFI_ShotProps-based (pre-computed physics, legacy path):
| Function | Description |
|---|---|
BCLIBCFFI_get_version() |
Library version string |
BCLIBCFFI_find_apex() |
Highest point of trajectory |
BCLIBCFFI_find_max_range() |
Maximum range and angle |
BCLIBCFFI_find_zero_angle() |
Barrel elevation to zero at distance |
BCLIBCFFI_integrate() |
Full trajectory, filtered by step/flags |
BCLIBCFFI_integrate_at() |
Single interpolated point at key value |
BCLIBCFFI_free_trajectory() |
Free memory from BCLIBCFFI_integrate |
BCLIBCFFI_get_correction() |
Angular correction for offset at distance |
BCLIBCFFI_calculate_energy() |
Kinetic energy (ft-lb) |
BCLIBCFFI_calculate_ogw() |
Optimal Game Weight |
BCLIBCFFI_Shot-based (natural units, preferred — all physics conversion in C++):
| Function | Description |
|---|---|
BCLIBCFFI_find_apex_shot() |
Highest point of trajectory |
BCLIBCFFI_find_max_range_shot() |
Maximum range and angle |
BCLIBCFFI_find_zero_angle_shot() |
Barrel elevation to zero at distance |
BCLIBCFFI_integrate_shot() |
Full trajectory, filtered by step/flags |
BCLIBCFFI_integrate_at_shot() |
Single interpolated point at key value |
BCLIBCFFI_Shot accepts raw user-facing units (temp_c, pressure_hpa, latitude_deg, azimuth_deg, parallel mach_data/cd_data arrays). All atmosphere density, Coriolis trig, PCHIP drag curve, and cant pre-computation are performed inside C++ by BCLIBC_Shot::to_shot_props().
Key types:
| Type | Description |
|---|---|
BCLIBCFFI_Shot |
Preferred shot input (natural units) |
BCLIBCFFI_ShotProps |
Legacy shot input (pre-computed physics) |
BCLIBCFFI_TrajectoryData |
One filtered trajectory record |
BCLIBCFFI_TrajectoryRequest |
Step / range / filter config for integrate |
BCLIBCFFI_Interception |
Single interpolated point from integrate_at |
BCLIBCFFI_MaxRangeResult |
Max range + angle from find_max_range |
BCLIBCFFI_Error |
Error code + message + typed extra fields |
On Windows, the DLL exports are controlled via __declspec(dllexport) (defined automatically when building the library). On Linux, a version script (src/ffi/bclibc_ffi.version) restricts the export table to BCLIBCFFI_* symbols only.
Verify exports:
# Linux
nm -D build/libbclibc_ffi.so | grep " T "
# macOS
nm -g build/libbclibc_ffi.dylib | grep " T "
# Windows
dumpbin /exports build\bin\Release\bclibc_ffi.dll- Copy the platform library to your Flutter project's native assets folder
- Load with
dart:ffi - Generate Dart bindings automatically using
ffigenwith the header:headers: entry-points: - include/bclibc/ffi/bclibc_ffi.h
| Workflow | Trigger | Description |
|---|---|---|
pr-check.yml |
PR to main/develop |
Builds on Linux, macOS, Windows × Debug/Release |
wasm-bare.yml |
PR touching src/include/cmake/... |
Builds the bare wasm module with both toolchains, runs parity.py |
build-libs.yml |
Manual | Build specific platform (including wasm) and upload artifacts |
release.yml |
Push tag v* |
Builds all platforms (including wasm) and creates GitHub Release |
Runs a clean build and validates artifacts, version metadata, and symbol visibility:
chmod +x pre-commit-check.sh
./pre-commit-check.sh.
├── include/
│ ├── bclibc.hpp
│ └── bclibc/
│ ├── base_types.hpp
│ ├── cash_karp.hpp
│ ├── dormand_prince.hpp
│ ├── embedded_rk45.hpp
│ ├── engine.hpp
│ ├── euler.hpp
│ ├── exceptions.hpp
│ ├── interp.hpp
│ ├── log.hpp
│ ├── rk4.hpp
│ ├── scope_guard.hpp
│ ├── traj_data.hpp
│ ├── traj_filter.hpp
│ ├── v3d.hpp
│ ├── velocity_verlet.hpp
│ └── ffi/
│ └── bclibc_ffi.h # Public C FFI API
│
├── src/
│ ├── base_types.cpp
│ ├── cash_karp.cpp
│ ├── dormand_prince.cpp
│ ├── engine.cpp
│ ├── euler.cpp
│ ├── interp.cpp
│ ├── rk4.cpp
│ ├── traj_data.cpp
│ ├── traj_filter.cpp
│ ├── velocity_verlet.cpp
│ └── ffi/
│ ├── bclibc_ffi.cpp
│ └── bclibc_ffi.version # Linux symbol visibility script
│
├── .github/workflows/
│ ├── build-libs.yml
│ ├── pr-check.yml
│ └── release.yml
│
├── CMakeLists.txt
├── Makefile
├── build.sh # Linux/macOS build script
├── build.ps1 # Windows PowerShell build script
├── build.bat # Windows CMD build script
├── clean.ps1 # Windows clean script
├── pre-commit-check.sh
├── version.h.in
└── LICENSE
Warning
This library performs approximate simulations of complex physical processes. Therefore, the calculation results MUST NOT be considered as completely and reliably > reflecting actual behavior of projectiles. While these results may be used for educational purpose, they must NOT be considered as reliable for the areas where incorrect calculation may cause making a wrong decision, financial harm, or can put a human life at risk.
THE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.