| Replace V with Nim 472eb49 nandithebull 7h ago | 1 | # nimflutter_ffi |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 2 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 3 | Write application logic in **Nim**, call it from **Flutter** over `dart:ffi`. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 4 | |
| 5 | Built on Flutter's `plugin_ffi` template. No platform channels, no method-channel |
| Replace V with Nim 472eb49 nandithebull 7h ago | 6 | serialisation — Dart calls Nim through the C ABI directly. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 7 | |
| 8 | ## How it works |
| 9 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 10 | Nim compiles to C. That is the whole trick: |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 11 | |
| 12 | ``` |
| Replace V with Nim 472eb49 nandithebull 7h ago | 13 | src/nimflutter.nim --[ nim c --compileOnly ]--> C --[ NDK / clang / MSVC ]--> libnimflutter.so |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 14 | ``` |
| 15 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 16 | Nim never cross-compiles for the target itself. It only emits C, and the |
| 17 | platform's own toolchain owns the ABI, sysroot and flags. That is why Android |
| 18 | arm64, iOS arm64, Linux, macOS, Windows and wasm32 all work from one source |
| 19 | file. |
| 20 | |
| 21 | One wrinkle versus a single-file emitter: Nim writes one `.c` per module plus |
| 22 | `nimbase.h`, and the set is only known after it runs. `src/CMakeLists.txt` |
| 23 | therefore runs Nim at *configure* time and globs the result, rather than |
| 24 | wiring an `add_custom_command`. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 25 | |
| 26 | | Platform | Built by | Artifact | |
| 27 | |---|---|---| |
| Replace V with Nim 472eb49 nandithebull 7h ago | 28 | | Android | NDK via `android/build.gradle` → `src/CMakeLists.txt` | `libnimflutter.so` per ABI | |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 29 | | Linux / Windows | Flutter's CMake → `src/CMakeLists.txt` | shared library, auto-bundled | |
| 30 | | iOS / macOS | CocoaPods compiles pre-generated C | static archive in the app binary | |
| 31 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 32 | iOS is the odd one out: Xcode's build sandbox can't run `nim`, and App Store |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 33 | builds want a static archive. So `tool/gen_ios_sources.sh` generates the C ahead |
| 34 | of time into `ios/Classes/`, and that is checked in. **Re-run it whenever |
| Replace V with Nim 472eb49 nandithebull 7h ago | 35 | `src/nimflutter.nim` changes.** |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 36 | |
| 37 | ## The two rules |
| 38 | |
| 39 | Everything that goes wrong with this bridge goes wrong in one of two ways. |
| 40 | |
| 41 | **1. Only C types cross the boundary.** |
| Replace V with Nim 472eb49 nandithebull 7h ago | 42 | `cint`, `cdouble`, `cstring`, `pointer`. Never a Nim `string`, `seq`, `ref` or |
| 43 | object — those have Nim-specific layouts and lifetimes Dart cannot read. |
| 44 | Convert at the edge. |
| 45 | |
| 46 | **2. Memory that crosses the boundary is manually owned, and comes from the |
| 47 | shared heap.** |
| 48 | |
| 49 | The library is built `--mm:arc`. ARC frees Nim's own temporaries |
| 50 | deterministically at scope exit, so the leak-every-temporary problem does not |
| 51 | arise — but anything handed *out* to Dart has deliberately escaped that, and |
| 52 | the caller must return it: |
| 53 | |
| 54 | ```nim |
| 55 | proc nf_greet(name: cstring): cstring {.exportc: "nf_greet", dynlib, cdecl.} = |
| 56 | let greeting = "Hello, " & $name & ", from Nim!" # ARC frees this at exit |
| 57 | # allocShared0, not alloc0: Dart may free this from a different isolate than |
| 58 | # the one that allocated it, and Nim's default heap is thread-local. |
| 59 | let buf = cast[cstring](allocShared0(greeting.len + 1)) |
| 60 | copyMem(buf, greeting.cstring, greeting.len) |
| 61 | buf # caller owns this |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 62 | ``` |
| 63 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 64 | `--mm:arc` rather than the default ORC because a C-ABI surface has no cycles |
| 65 | to collect, and no reason to pay for a cycle detector. |
| 66 | |
| 67 | Ownership across the boundary: **Nim allocates, Dart copies, Nim frees.** The |
| 68 | Dart wrapper in `lib/nimflutter_ffi.dart` does the `nf_free` in a `finally`, so |
| 69 | callers never hold a pointer and never leak. `tool/../example/lib/memory_check.dart` |
| 70 | holds RSS flat across 900k round trips. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 71 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 72 | Better still, for bulk data, don't allocate at all: let Dart own the buffer and |
| 73 | have Nim fill it in place. `nf_mandelbrot` works that way, which is why it |
| 74 | needs no `nf_free` — and why it takes a `buf_len` it validates in 64-bit before |
| 75 | writing a single byte. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 76 | |
| Add a justfile f7c3825 nandithebull 9h ago | 77 | ## Quick start |
| 78 | |
| 79 | With [`just`](https://github.com/casey/just): |
| 80 | |
| 81 | ``` |
| Replace V with Nim 472eb49 nandithebull 7h ago | 82 | just mandelbrot # build the Nim library and run the fractal explorer |
| 83 | just web # serve the same app with Nim as WebAssembly (no browser opened) |
| Add a justfile f7c3825 nandithebull 9h ago | 84 | just test # analyze + the example's test suite |
| Replace V with Nim 472eb49 nandithebull 7h ago | 85 | just bench # Nim vs Dart timings for the same frame |
| Add a justfile f7c3825 nandithebull 9h ago | 86 | just --list # everything else |
| 87 | ``` |
| 88 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 89 | Flutter is expected at `~/flutter/bin` and Nim at `~/nim/bin`; set |
| 90 | `FLUTTER_BIN` or `NIM_BIN` if yours live elsewhere. |
| Add a justfile f7c3825 nandithebull 9h ago | 91 | |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 92 | ## Usage |
| 93 | |
| 94 | ```dart |
| Replace V with Nim 472eb49 nandithebull 7h ago | 95 | import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v; |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 96 | |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 97 | await v.initialize(); // required on web, a formality on native |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 98 | v.add(20, 22); // 42 |
| Replace V with Nim 472eb49 nandithebull 7h ago | 99 | v.greet('Flutter'); // "Hello, Flutter, from Nim!" |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 100 | await v.greetAsync('isolate'); // same, off the UI isolate |
| 101 | ``` |
| 102 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 103 | ARC has no stop-the-world phase, so calls are safe from any isolate — provided |
| 104 | anything handed across the boundary comes from the shared heap, as above. Use |
| 105 | `Isolate.run` for anything long enough to jank a frame. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 106 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 107 | For bulk data, let Dart own the buffer and have Nim fill it in place — then |
| Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 9h ago | 108 | nothing crosses the boundary that needs freeing, and independent slices can be |
| 109 | computed concurrently: |
| 110 | |
| 111 | ```dart |
| 112 | final view = v.FractalView(width: 800, height: 600, maxIter: 500); |
| 113 | final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888 |
| 114 | ``` |
| 115 | |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 116 | ## Web |
| 117 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 118 | The same Nim source also runs in the browser. `just web` compiles it to |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 119 | WebAssembly with emscripten and serves the example app at |
| 120 | `http://localhost:8080` — it prints the URL and waits rather than launching a |
| 121 | browser, so open it yourself (`WEB_PORT=9000 just web` to move it). Hot |
| 122 | restart still works. `dart:ffi` does not exist on web, so a second backend |
| 123 | drives the wasm module over `dart:js_interop` instead: |
| 124 | |
| 125 | ``` |
| 126 | lib/src/backend_native.dart dart:ffi |
| 127 | lib/src/backend_web.dart dart:js_interop + emscripten |
| Replace V with Nim 472eb49 nandithebull 7h ago | 128 | lib/nimflutter_ffi.dart picks one with a conditional import |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 129 | ``` |
| 130 | |
| 131 | Nothing has to be installed for this. `tool/bin/emcc` is a |
| 132 | [DotSlash](https://dotslash-cli.com) file that pins emscripten by content |
| 133 | hash; the toolchain is fetched on first use and cached in `~/.cache/dotslash`. |
| 134 | Only `dotslash` itself needs to be on `PATH`. |
| 135 | |
| 136 | The two backends are held to being the same, not merely similar: `just parity` |
| 137 | renders a frame with each kernel and compares them byte for byte, and they |
| 138 | agree exactly — across different compilers, different libm implementations |
| 139 | (glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close |
| 140 | too, ~2-5% off native for the same frame: |
| 141 | |
| 142 | ``` |
| Replace V with Nim 472eb49 nandithebull 7h ago | 143 | 800x600 maxIter 100: 68.3 ms native / 70.2 ms wasm |
| 144 | 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 145 | ``` |
| 146 | |
| 147 | Two differences are real and surfaced in the API: |
| 148 | |
| 149 | * `await v.initialize()` before anything else. A wasm module cannot be |
| 150 | instantiated synchronously. On native it resolves immediately. |
| 151 | * `v.supportsIsolateParallelism` is false on web. There are no isolates, so |
| 152 | `renderParallel` still splits the frame and still produces identical pixels, |
| 153 | but the bands run in sequence. |
| 154 | |
| 155 | ### One trap worth knowing about |
| 156 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 157 | Nim's `vmemcpy` silently skips any copy whose source or destination is |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 158 | `<= 0xFFFF` — a null-pointer heuristic that is sound on 64-bit desktop, where |
| 159 | the low 64 KB is never mapped. On wasm32 emscripten packs static data down at |
| Replace V with Nim 472eb49 nandithebull 7h ago | 160 | address ~1300, so every copy out of a string literal does nothing and Nim strings |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 161 | come back as runs of zero bytes, with no crash and no diagnostic. It only |
| 162 | appears at `-O1` and above, which makes it look like an optimiser bug. |
| 163 | `tool/build_wasm.sh` passes `-sGLOBAL_BASE=1048576` to move static data, the |
| 164 | stack and the heap clear of that check. |
| 165 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 166 | ## Is Nim faster than Dart? |
| Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 9h ago | 167 | |
| 168 | For the numeric kernel in the example: **no, not meaningfully.** Measured at |
| Replace V with Nim 472eb49 nandithebull 7h ago | 169 | 800x600 / 500 iterations on an 8-core Linux box, Nim through C takes ~152 ms |
| 170 | against Dart AOT's ~169 ms — about 1.1x, which is inside the range where the |
| 171 | answer depends on the loop rather than the language. Dart's AOT compiler is |
| 172 | good at scalar floating-point loops. (V, which this plugin used previously, |
| 173 | measured the same ~1.1x.) |
| 174 | |
| 175 | What the bridge does buy you is the ability to *write the logic in Nim* — or |
| 176 | reuse Nim you already have — with a C ABI that is cheap to call, safe to call |
| 177 | concurrently, and that also compiles to wasm for the web build. Pick it for the |
| 178 | language, not for an expected speedup. And build with optimisation on: at |
| 179 | `-O0`, or without `-d:danger`, the same kernel is several times slower, which |
| 180 | is what `tool/build.sh` and `src/CMakeLists.txt` guard against. |
| 181 | |
| 182 | The parallel figures in the example come from splitting the frame across |
| 183 | isolates, not from the language — see `just sweep`, which shows the best band |
| 184 | count is well above the core count because the bands cost wildly different |
| 185 | amounts. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 186 | |
| 187 | ## Status |
| 188 | |
| Replace V with Nim 472eb49 nandithebull 7h ago | 189 | Verified on Linux with Nim 2.2.0, Dart 3 and Flutter 3.47: host build, string |
| Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 9h ago | 190 | round-trip, isolate dispatch, 900k-call memory stability, and the example app |
| 191 | built and run as a release Linux binary (CMake -> `v` -> NDK/clang path |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 192 | included). |
| 193 | |
| 194 | The web path is verified end to end as well — wasm built with emscripten 6.0.9, |
| 195 | the module exercised headlessly under node, the kernel compared byte for byte |
| 196 | against the native build, and the release web app loaded in headless Chrome |
| 197 | with its rendered canvas checked for an actual fractal. `just ci` runs all of |
| 198 | it. |
| 199 | |
| 200 | The Android/iOS/Windows glue is written to the standard `plugin_ffi` contract |
| 201 | but is not exercised here — it needs the respective toolchains. |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 202 | |
| 203 | ## Layout |
| 204 | |
| 205 | ``` |
| Replace V with Nim 472eb49 nandithebull 7h ago | 206 | src/nimflutter.v the Nim source — the only file you normally edit |
| 207 | src/nimflutter.h C declarations (ffigen input, Xcode input) |
| 208 | src/CMakeLists.txt Nim -> C -> shared lib; shared by Android/Linux/Windows |
| 209 | lib/nimflutter_ffi.dart the Dart API callers use |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 210 | lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 211 | ios/, macos/ pre-generated C + podspec (static archive) |
| 212 | android/build.gradle NDK build via externalNativeBuild |
| 213 | tool/build.sh host build + export smoke test |
| Replace V with Nim 472eb49 nandithebull 7h ago | 214 | tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the Nim source |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 215 | tool/bin/emcc DotSlash file pinning emscripten — nothing to install |
| Replace V with Nim 472eb49 nandithebull 7h ago | 216 | tool/build_wasm.sh Nim -> C -> wasm for the web build |
| Add a WebAssembly backend for the example 728acaa nandithebull 7h ago | 217 | tool/test_wasm.mjs headless check of the wasm module (no browser needed) |
| 218 | tool/check_parity.sh native vs wasm kernel, byte for byte |
| 219 | tool/check_web.sh loads the built web app in headless Chrome |
| Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 9h ago | 220 | example/ Flutter app exercising the bridge (see example/README.md) |
| Initial commit: V -> Flutter FFI plugin ad0ccda nandi 13h ago | 221 | ``` |