# nimflutter_ffi Write application logic in **Nim**, call it from **Flutter** over `dart:ffi`. Built on Flutter's `plugin_ffi` template. No platform channels, no method-channel serialisation — Dart calls Nim through the C ABI directly. ## How it works Nim compiles to C. That is the whole trick: ``` src/nimflutter.nim --[ nim c --compileOnly ]--> C --[ NDK / clang / MSVC ]--> libnimflutter.so ``` Nim never cross-compiles for the target itself. It only emits C, and the platform's own toolchain owns the ABI, sysroot and flags. That is why Android arm64, iOS arm64, Linux, macOS, Windows and wasm32 all work from one source file. One wrinkle versus a single-file emitter: Nim writes one `.c` per module plus `nimbase.h`, and the set is only known after it runs. `src/CMakeLists.txt` therefore runs Nim at *configure* time and globs the result, rather than wiring an `add_custom_command`. | Platform | Built by | Artifact | |---|---|---| | Android | NDK via `android/build.gradle` → `src/CMakeLists.txt` | `libnimflutter.so` per ABI | | Linux / Windows | Flutter's CMake → `src/CMakeLists.txt` | shared library, auto-bundled | | iOS / macOS | CocoaPods compiles pre-generated C | static archive in the app binary | iOS is the odd one out: Xcode's build sandbox can't run `nim`, and App Store builds want a static archive. So `tool/gen_ios_sources.sh` generates the C ahead of time into `ios/Classes/`, and that is checked in. **Re-run it whenever `src/nimflutter.nim` changes.** ## The two rules Everything that goes wrong with this bridge goes wrong in one of two ways. **1. Only C types cross the boundary.** `cint`, `cdouble`, `cstring`, `pointer`. Never a Nim `string`, `seq`, `ref` or object — those have Nim-specific layouts and lifetimes Dart cannot read. Convert at the edge. **2. Memory that crosses the boundary is manually owned, and comes from the shared heap.** The library is built `--mm:arc`. ARC frees Nim's own temporaries deterministically at scope exit, so the leak-every-temporary problem does not arise — but anything handed *out* to Dart has deliberately escaped that, and the caller must return it: ```nim proc nf_greet(name: cstring): cstring {.exportc: "nf_greet", dynlib, cdecl.} = let greeting = "Hello, " & $name & ", from Nim!" # ARC frees this at exit # allocShared0, not alloc0: Dart may free this from a different isolate than # the one that allocated it, and Nim's default heap is thread-local. let buf = cast[cstring](allocShared0(greeting.len + 1)) copyMem(buf, greeting.cstring, greeting.len) buf # caller owns this ``` `--mm:arc` rather than the default ORC because a C-ABI surface has no cycles to collect, and no reason to pay for a cycle detector. Ownership across the boundary: **Nim allocates, Dart copies, Nim frees.** The Dart wrapper in `lib/nimflutter_ffi.dart` does the `nf_free` in a `finally`, so callers never hold a pointer and never leak. `tool/../example/lib/memory_check.dart` holds RSS flat across 900k round trips. Better still, for bulk data, don't allocate at all: let Dart own the buffer and have Nim fill it in place. `nf_mandelbrot` works that way, which is why it needs no `nf_free` — and why it takes a `buf_len` it validates in 64-bit before writing a single byte. ## Quick start With [`just`](https://github.com/casey/just): ``` just mandelbrot # build the Nim library and run the fractal explorer just web # serve the same app with Nim as WebAssembly (no browser opened) just test # analyze + the example's test suite just bench # Nim vs Dart timings for the same frame just --list # everything else ``` Flutter is expected at `~/flutter/bin` and Nim at `~/nim/bin`; set `FLUTTER_BIN` or `NIM_BIN` if yours live elsewhere. ## Usage ```dart import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v; await v.initialize(); // required on web, a formality on native v.add(20, 22); // 42 v.greet('Flutter'); // "Hello, Flutter, from Nim!" await v.greetAsync('isolate'); // same, off the UI isolate ``` ARC has no stop-the-world phase, so calls are safe from any isolate — provided anything handed across the boundary comes from the shared heap, as above. Use `Isolate.run` for anything long enough to jank a frame. For bulk data, let Dart own the buffer and have Nim fill it in place — then nothing crosses the boundary that needs freeing, and independent slices can be computed concurrently: ```dart final view = v.FractalView(width: 800, height: 600, maxIter: 500); final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888 ``` ## Web The same Nim source also runs in the browser. `just web` compiles it to WebAssembly with emscripten and serves the example app at `http://localhost:8080` — it prints the URL and waits rather than launching a browser, so open it yourself (`WEB_PORT=9000 just web` to move it). Hot restart still works. `dart:ffi` does not exist on web, so a second backend drives the wasm module over `dart:js_interop` instead: ``` lib/src/backend_native.dart dart:ffi lib/src/backend_web.dart dart:js_interop + emscripten lib/nimflutter_ffi.dart picks one with a conditional import ``` Nothing has to be installed for this. `tool/bin/emcc` is a [DotSlash](https://dotslash-cli.com) file that pins emscripten by content hash; the toolchain is fetched on first use and cached in `~/.cache/dotslash`. Only `dotslash` itself needs to be on `PATH`. The two backends are held to being the same, not merely similar: `just parity` renders a frame with each kernel and compares them byte for byte, and they agree exactly — across different compilers, different libm implementations (glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close too, ~2-5% off native for the same frame: ``` 800x600 maxIter 100: 68.3 ms native / 70.2 ms wasm 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm ``` Web renders in parallel too, without `SharedArrayBuffer` or COOP/COEP headers: instead of threading one module, the pool runs *several instances* of it, one per Web Worker, each with its own linear memory. Nothing is shared, so there is nothing to synchronise, and a module is only ~34 KB. Measured in headless Chrome on 8 cores, 800x600 at 500 iterations: ``` main thread, one band 345 ms 1 worker 164 ms 32 bands over 8 workers 37 ms (4.5x) ``` More bands than workers, for the same reason as native: band costs are wildly uneven, so over-decomposing keeps a free worker busy rather than waiting on the slowest one. `just check-pool` re-measures this and asserts the pooled frame is byte-identical to a single-instance render — a pool that quietly falls back to one thread, or assembles bands out of order, otherwise looks just like a working one. One difference remains, and it is surfaced in the API: * `await v.initialize()` before anything else. A wasm module cannot be instantiated synchronously. On native it resolves immediately. ### One trap worth knowing about Nim's `vmemcpy` silently skips any copy whose source or destination is `<= 0xFFFF` — a null-pointer heuristic that is sound on 64-bit desktop, where the low 64 KB is never mapped. On wasm32 emscripten packs static data down at address ~1300, so every copy out of a string literal does nothing and Nim strings come back as runs of zero bytes, with no crash and no diagnostic. It only appears at `-O1` and above, which makes it look like an optimiser bug. `tool/build_wasm.sh` passes `-sGLOBAL_BASE=1048576` to move static data, the stack and the heap clear of that check. ## Is Nim faster than Dart? For the numeric kernel in the example: **no, not meaningfully.** Measured at 800x600 / 500 iterations on an 8-core Linux box, Nim through C takes ~152 ms against Dart AOT's ~169 ms — about 1.1x, which is inside the range where the answer depends on the loop rather than the language. Dart's AOT compiler is good at scalar floating-point loops. (V, which this plugin used previously, measured the same ~1.1x.) What the bridge does buy you is the ability to *write the logic in Nim* — or reuse Nim you already have — with a C ABI that is cheap to call, safe to call concurrently, and that also compiles to wasm for the web build. Pick it for the language, not for an expected speedup. And build with optimisation on: at `-O0`, or without `-d:danger`, the same kernel is several times slower, which is what `tool/build.sh` and `src/CMakeLists.txt` guard against. The parallel figures in the example come from splitting the frame across isolates, not from the language — see `just sweep`, which shows the best band count is well above the core count because the bands cost wildly different amounts. ## Status Verified on Linux with Nim 2.2.0, Dart 3 and Flutter 3.47: host build, string round-trip, isolate dispatch, 900k-call memory stability, and the example app built and run as a release Linux binary (CMake -> `v` -> NDK/clang path included). The web path is verified end to end as well — wasm built with emscripten 6.0.9, the module exercised headlessly under node, the kernel compared byte for byte against the native build, and the release web app loaded in headless Chrome with its rendered canvas checked for an actual fractal. `just ci` runs all of it. The Android/iOS/Windows glue is written to the standard `plugin_ffi` contract but is not exercised here — it needs the respective toolchains. ## Layout ``` src/nimflutter.v the Nim source — the only file you normally edit src/nimflutter.h C declarations (ffigen input, Xcode input) src/CMakeLists.txt Nim -> C -> shared lib; shared by Android/Linux/Windows lib/nimflutter_ffi.dart the Dart API callers use lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop ios/, macos/ pre-generated C + podspec (static archive) android/build.gradle NDK build via externalNativeBuild tool/build.sh host build + export smoke test tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the Nim source tool/bin/emcc DotSlash file pinning emscripten — nothing to install tool/build_wasm.sh Nim -> C -> wasm for the web build tool/test_wasm.mjs headless check of the wasm module (no browser needed) tool/check_parity.sh native vs wasm kernel, byte for byte tool/check_web.sh loads the built web app in headless Chrome example/ Flutter app exercising the bridge (see example/README.md) ```