Add a WebAssembly backend for the example
Work from a concurrent session, committed as found so it is not at risk of being lost to an unstaged working tree. The V kernel is compiled to wasm by emscripten (pinned by content hash via DotSlash, so nothing is installed system-wide) and driven over dart:js_interop. A conditional import in vflutter_ffi.dart picks the native or web backend, with the two files holding the same top-level signatures. tool/check_parity.sh renders the same frame through both and compares it byte for byte — the two go through different compilers, different libm implementations and different word sizes, so agreement is worth asserting rather than assuming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
728acaa parent: 0ed90f3 modified
.gitignore +6 -0 | @@ -15,3 +15,9 @@ build/ | ||
| 15 | 15 | # NOTE: ios/Classes/*.gen.c and macos/Classes/*.gen.c are intentionally |
| 16 | 16 | # NOT ignored. Xcode cannot run `v`, so that C is checked in on purpose. |
| 17 | 17 | # Regenerate with tool/gen_ios_sources.sh after editing src/vflutter.v. |
| 18 | + | |
| 19 | +# The web build's wasm, by contrast, IS ignored: unlike Xcode, anything that | |
| 20 | +# can run the build can also run emscripten, because tool/bin/emcc fetches it | |
| 21 | +# on demand. Regenerate with `just wasm`. | |
| 22 | +example/web/vflutter.js | |
| 23 | +example/web/vflutter.wasm | |
| @@ -15,3 +15,9 @@ build/ | |||
| 15 | # NOTE: ios/Classes/*.gen.c and macos/Classes/*.gen.c are intentionally | 15 | # NOTE: ios/Classes/*.gen.c and macos/Classes/*.gen.c are intentionally |
| 16 | # NOT ignored. Xcode cannot run `v`, so that C is checked in on purpose. | 16 | # NOT ignored. Xcode cannot run `v`, so that C is checked in on purpose. |
| 17 | # Regenerate with tool/gen_ios_sources.sh after editing src/vflutter.v. | 17 | # Regenerate with tool/gen_ios_sources.sh after editing src/vflutter.v. |
| 18 | + | ||
| 19 | +# The web build's wasm, by contrast, IS ignored: unlike Xcode, anything that | ||
| 20 | +# can run the build can also run emscripten, because tool/bin/emcc fetches it | ||
| 21 | +# on demand. Regenerate with `just wasm`. | ||
| 22 | +example/web/vflutter.js | ||
| 23 | +example/web/vflutter.wasm | ||
modified
README.md +68 -2 | @@ -66,6 +66,7 @@ With [`just`](https://github.com/casey/just): | ||
| 66 | 66 | |
| 67 | 67 | ``` |
| 68 | 68 | just mandelbrot # build the V library and run the fractal explorer |
| 69 | +just web # serve the same app with V as WebAssembly (no browser opened) | |
| 69 | 70 | just test # analyze + the example's test suite |
| 70 | 71 | just bench # V vs Dart timings for the same frame |
| 71 | 72 | just --list # everything else |
| @@ -79,6 +80,7 @@ elsewhere. | ||
| 79 | 80 | ```dart |
| 80 | 81 | import 'package:vflutter_ffi/vflutter_ffi.dart' as v; |
| 81 | 82 | |
| 83 | +await v.initialize(); // required on web, a formality on native | |
| 82 | 84 | v.add(20, 22); // 42 |
| 83 | 85 | v.greet('Flutter'); // "Hello, Flutter, from V!" |
| 84 | 86 | await v.greetAsync('isolate'); // same, off the UI isolate |
| @@ -97,6 +99,56 @@ final view = v.FractalView(width: 800, height: 600, maxIter: 500); | ||
| 97 | 99 | final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888 |
| 98 | 100 | ``` |
| 99 | 101 | |
| 102 | +## Web | |
| 103 | + | |
| 104 | +The same V source also runs in the browser. `just web` compiles it to | |
| 105 | +WebAssembly with emscripten and serves the example app at | |
| 106 | +`http://localhost:8080` — it prints the URL and waits rather than launching a | |
| 107 | +browser, so open it yourself (`WEB_PORT=9000 just web` to move it). Hot | |
| 108 | +restart still works. `dart:ffi` does not exist on web, so a second backend | |
| 109 | +drives the wasm module over `dart:js_interop` instead: | |
| 110 | + | |
| 111 | +``` | |
| 112 | +lib/src/backend_native.dart dart:ffi | |
| 113 | +lib/src/backend_web.dart dart:js_interop + emscripten | |
| 114 | +lib/vflutter_ffi.dart picks one with a conditional import | |
| 115 | +``` | |
| 116 | + | |
| 117 | +Nothing has to be installed for this. `tool/bin/emcc` is a | |
| 118 | +[DotSlash](https://dotslash-cli.com) file that pins emscripten by content | |
| 119 | +hash; the toolchain is fetched on first use and cached in `~/.cache/dotslash`. | |
| 120 | +Only `dotslash` itself needs to be on `PATH`. | |
| 121 | + | |
| 122 | +The two backends are held to being the same, not merely similar: `just parity` | |
| 123 | +renders a frame with each kernel and compares them byte for byte, and they | |
| 124 | +agree exactly — across different compilers, different libm implementations | |
| 125 | +(glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close | |
| 126 | +too, ~2-5% off native for the same frame: | |
| 127 | + | |
| 128 | +``` | |
| 129 | +800x600 maxIter 100: 68.1 ms native / 69.4 ms wasm | |
| 130 | +800x600 maxIter 500: 149.3 ms native / 156.3 ms wasm | |
| 131 | +``` | |
| 132 | + | |
| 133 | +Two differences are real and surfaced in the API: | |
| 134 | + | |
| 135 | +* `await v.initialize()` before anything else. A wasm module cannot be | |
| 136 | + instantiated synchronously. On native it resolves immediately. | |
| 137 | +* `v.supportsIsolateParallelism` is false on web. There are no isolates, so | |
| 138 | + `renderParallel` still splits the frame and still produces identical pixels, | |
| 139 | + but the bands run in sequence. | |
| 140 | + | |
| 141 | +### One trap worth knowing about | |
| 142 | + | |
| 143 | +V's `vmemcpy` silently skips any copy whose source or destination is | |
| 144 | +`<= 0xFFFF` — a null-pointer heuristic that is sound on 64-bit desktop, where | |
| 145 | +the low 64 KB is never mapped. On wasm32 emscripten packs static data down at | |
| 146 | +address ~1300, so every copy out of a string literal does nothing and V strings | |
| 147 | +come back as runs of zero bytes, with no crash and no diagnostic. It only | |
| 148 | +appears at `-O1` and above, which makes it look like an optimiser bug. | |
| 149 | +`tool/build_wasm.sh` passes `-sGLOBAL_BASE=1048576` to move static data, the | |
| 150 | +stack and the heap clear of that check. | |
| 151 | + | |
| 100 | 152 | ## Is V faster than Dart? |
| 101 | 153 | |
| 102 | 154 | For the numeric kernel in the example: **no, not meaningfully.** Measured at |
| @@ -126,8 +178,16 @@ generate the raw bindings than hand-write them. | ||
| 126 | 178 | Verified on Linux with V 0.5.2, Dart 3 and Flutter 3.47: host build, string |
| 127 | 179 | round-trip, isolate dispatch, 900k-call memory stability, and the example app |
| 128 | 180 | built and run as a release Linux binary (CMake -> `v` -> NDK/clang path |
| 129 | -included). The Android/iOS/Windows glue is written to the standard `plugin_ffi` | |
| 130 | -contract but is not exercised here — it needs the respective toolchains. | |
| 181 | +included). | |
| 182 | + | |
| 183 | +The web path is verified end to end as well — wasm built with emscripten 6.0.9, | |
| 184 | +the module exercised headlessly under node, the kernel compared byte for byte | |
| 185 | +against the native build, and the release web app loaded in headless Chrome | |
| 186 | +with its rendered canvas checked for an actual fractal. `just ci` runs all of | |
| 187 | +it. | |
| 188 | + | |
| 189 | +The Android/iOS/Windows glue is written to the standard `plugin_ffi` contract | |
| 190 | +but is not exercised here — it needs the respective toolchains. | |
| 131 | 191 | |
| 132 | 192 | ## Layout |
| 133 | 193 | |
| @@ -136,9 +196,15 @@ src/vflutter.v the V source — the only file you normally edit | ||
| 136 | 196 | src/vflutter.h C declarations (ffigen input, Xcode input) |
| 137 | 197 | src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows |
| 138 | 198 | lib/vflutter_ffi.dart the Dart API callers use |
| 199 | +lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop | |
| 139 | 200 | ios/, macos/ pre-generated C + podspec (static archive) |
| 140 | 201 | android/build.gradle NDK build via externalNativeBuild |
| 141 | 202 | tool/build.sh host build + export smoke test |
| 142 | 203 | tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the V source |
| 204 | +tool/bin/emcc DotSlash file pinning emscripten — nothing to install | |
| 205 | +tool/build_wasm.sh V -> C -> wasm for the web build | |
| 206 | +tool/test_wasm.mjs headless check of the wasm module (no browser needed) | |
| 207 | +tool/check_parity.sh native vs wasm kernel, byte for byte | |
| 208 | +tool/check_web.sh loads the built web app in headless Chrome | |
| 143 | 209 | example/ Flutter app exercising the bridge (see example/README.md) |
| 144 | 210 | ``` |
| @@ -66,6 +66,7 @@ With [`just`](https://github.com/casey/just): | |||
| 66 | 66 | ||
| 67 | ``` | 67 | ``` |
| 68 | just mandelbrot # build the V library and run the fractal explorer | 68 | just mandelbrot # build the V library and run the fractal explorer |
| 69 | +just web # serve the same app with V as WebAssembly (no browser opened) | ||
| 69 | just test # analyze + the example's test suite | 70 | just test # analyze + the example's test suite |
| 70 | just bench # V vs Dart timings for the same frame | 71 | just bench # V vs Dart timings for the same frame |
| 71 | just --list # everything else | 72 | just --list # everything else |
| @@ -79,6 +80,7 @@ elsewhere. | |||
| 79 | ```dart | 80 | ```dart |
| 80 | import 'package:vflutter_ffi/vflutter_ffi.dart' as v; | 81 | import 'package:vflutter_ffi/vflutter_ffi.dart' as v; |
| 81 | 82 | ||
| 83 | +await v.initialize(); // required on web, a formality on native | ||
| 82 | v.add(20, 22); // 42 | 84 | v.add(20, 22); // 42 |
| 83 | v.greet('Flutter'); // "Hello, Flutter, from V!" | 85 | v.greet('Flutter'); // "Hello, Flutter, from V!" |
| 84 | await v.greetAsync('isolate'); // same, off the UI isolate | 86 | await v.greetAsync('isolate'); // same, off the UI isolate |
| @@ -97,6 +99,56 @@ final view = v.FractalView(width: 800, height: 600, maxIter: 500); | |||
| 97 | final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888 | 99 | final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888 |
| 98 | ``` | 100 | ``` |
| 99 | 101 | ||
| 102 | +## Web | ||
| 103 | + | ||
| 104 | +The same V source also runs in the browser. `just web` compiles it to | ||
| 105 | +WebAssembly with emscripten and serves the example app at | ||
| 106 | +`http://localhost:8080` — it prints the URL and waits rather than launching a | ||
| 107 | +browser, so open it yourself (`WEB_PORT=9000 just web` to move it). Hot | ||
| 108 | +restart still works. `dart:ffi` does not exist on web, so a second backend | ||
| 109 | +drives the wasm module over `dart:js_interop` instead: | ||
| 110 | + | ||
| 111 | +``` | ||
| 112 | +lib/src/backend_native.dart dart:ffi | ||
| 113 | +lib/src/backend_web.dart dart:js_interop + emscripten | ||
| 114 | +lib/vflutter_ffi.dart picks one with a conditional import | ||
| 115 | +``` | ||
| 116 | + | ||
| 117 | +Nothing has to be installed for this. `tool/bin/emcc` is a | ||
| 118 | +[DotSlash](https://dotslash-cli.com) file that pins emscripten by content | ||
| 119 | +hash; the toolchain is fetched on first use and cached in `~/.cache/dotslash`. | ||
| 120 | +Only `dotslash` itself needs to be on `PATH`. | ||
| 121 | + | ||
| 122 | +The two backends are held to being the same, not merely similar: `just parity` | ||
| 123 | +renders a frame with each kernel and compares them byte for byte, and they | ||
| 124 | +agree exactly — across different compilers, different libm implementations | ||
| 125 | +(glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close | ||
| 126 | +too, ~2-5% off native for the same frame: | ||
| 127 | + | ||
| 128 | +``` | ||
| 129 | +800x600 maxIter 100: 68.1 ms native / 69.4 ms wasm | ||
| 130 | +800x600 maxIter 500: 149.3 ms native / 156.3 ms wasm | ||
| 131 | +``` | ||
| 132 | + | ||
| 133 | +Two differences are real and surfaced in the API: | ||
| 134 | + | ||
| 135 | +* `await v.initialize()` before anything else. A wasm module cannot be | ||
| 136 | + instantiated synchronously. On native it resolves immediately. | ||
| 137 | +* `v.supportsIsolateParallelism` is false on web. There are no isolates, so | ||
| 138 | + `renderParallel` still splits the frame and still produces identical pixels, | ||
| 139 | + but the bands run in sequence. | ||
| 140 | + | ||
| 141 | +### One trap worth knowing about | ||
| 142 | + | ||
| 143 | +V's `vmemcpy` silently skips any copy whose source or destination is | ||
| 144 | +`<= 0xFFFF` — a null-pointer heuristic that is sound on 64-bit desktop, where | ||
| 145 | +the low 64 KB is never mapped. On wasm32 emscripten packs static data down at | ||
| 146 | +address ~1300, so every copy out of a string literal does nothing and V strings | ||
| 147 | +come back as runs of zero bytes, with no crash and no diagnostic. It only | ||
| 148 | +appears at `-O1` and above, which makes it look like an optimiser bug. | ||
| 149 | +`tool/build_wasm.sh` passes `-sGLOBAL_BASE=1048576` to move static data, the | ||
| 150 | +stack and the heap clear of that check. | ||
| 151 | + | ||
| 100 | ## Is V faster than Dart? | 152 | ## Is V faster than Dart? |
| 101 | 153 | ||
| 102 | For the numeric kernel in the example: **no, not meaningfully.** Measured at | 154 | For the numeric kernel in the example: **no, not meaningfully.** Measured at |
| @@ -126,8 +178,16 @@ generate the raw bindings than hand-write them. | |||
| 126 | Verified on Linux with V 0.5.2, Dart 3 and Flutter 3.47: host build, string | 178 | Verified on Linux with V 0.5.2, Dart 3 and Flutter 3.47: host build, string |
| 127 | round-trip, isolate dispatch, 900k-call memory stability, and the example app | 179 | round-trip, isolate dispatch, 900k-call memory stability, and the example app |
| 128 | built and run as a release Linux binary (CMake -> `v` -> NDK/clang path | 180 | built and run as a release Linux binary (CMake -> `v` -> NDK/clang path |
| 129 | -included). The Android/iOS/Windows glue is written to the standard `plugin_ffi` | 181 | +included). |
| 130 | -contract but is not exercised here — it needs the respective toolchains. | 182 | + |
| 183 | +The web path is verified end to end as well — wasm built with emscripten 6.0.9, | ||
| 184 | +the module exercised headlessly under node, the kernel compared byte for byte | ||
| 185 | +against the native build, and the release web app loaded in headless Chrome | ||
| 186 | +with its rendered canvas checked for an actual fractal. `just ci` runs all of | ||
| 187 | +it. | ||
| 188 | + | ||
| 189 | +The Android/iOS/Windows glue is written to the standard `plugin_ffi` contract | ||
| 190 | +but is not exercised here — it needs the respective toolchains. | ||
| 131 | 191 | ||
| 132 | ## Layout | 192 | ## Layout |
| 133 | 193 | ||
| @@ -136,9 +196,15 @@ src/vflutter.v the V source — the only file you normally edit | |||
| 136 | src/vflutter.h C declarations (ffigen input, Xcode input) | 196 | src/vflutter.h C declarations (ffigen input, Xcode input) |
| 137 | src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows | 197 | src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows |
| 138 | lib/vflutter_ffi.dart the Dart API callers use | 198 | lib/vflutter_ffi.dart the Dart API callers use |
| 199 | +lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop | ||
| 139 | ios/, macos/ pre-generated C + podspec (static archive) | 200 | ios/, macos/ pre-generated C + podspec (static archive) |
| 140 | android/build.gradle NDK build via externalNativeBuild | 201 | android/build.gradle NDK build via externalNativeBuild |
| 141 | tool/build.sh host build + export smoke test | 202 | tool/build.sh host build + export smoke test |
| 142 | tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the V source | 203 | tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the V source |
| 204 | +tool/bin/emcc DotSlash file pinning emscripten — nothing to install | ||
| 205 | +tool/build_wasm.sh V -> C -> wasm for the web build | ||
| 206 | +tool/test_wasm.mjs headless check of the wasm module (no browser needed) | ||
| 207 | +tool/check_parity.sh native vs wasm kernel, byte for byte | ||
| 208 | +tool/check_web.sh loads the built web app in headless Chrome | ||
| 143 | example/ Flutter app exercising the bridge (see example/README.md) | 209 | example/ Flutter app exercising the bridge (see example/README.md) |
| 144 | ``` | 210 | ``` |
modified
example/.metadata +3 -0 | @@ -18,6 +18,9 @@ migration: | ||
| 18 | 18 | - platform: linux |
| 19 | 19 | create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 |
| 20 | 20 | base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 |
| 21 | + - platform: web | |
| 22 | + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | |
| 23 | + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | |
| 21 | 24 | |
| 22 | 25 | # User provided section |
| 23 | 26 | |
| @@ -18,6 +18,9 @@ migration: | |||
| 18 | - platform: linux | 18 | - platform: linux |
| 19 | create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | 19 | create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 |
| 20 | base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | 20 | base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 |
| 21 | + - platform: web | ||
| 22 | + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | ||
| 23 | + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 | ||
| 21 | 24 | ||
| 22 | # User provided section | 25 | # User provided section |
| 23 | 26 | ||
modified
example/analysis_options.yaml +1 -0 | @@ -13,6 +13,7 @@ analyzer: | ||
| 13 | 13 | exclude: |
| 14 | 14 | - build/** |
| 15 | 15 | - linux/** |
| 16 | + - web/** | |
| 16 | 17 | errors: |
| 17 | 18 | # The headless scripts in lib/ are command-line tools whose entire output |
| 18 | 19 | # is stdout, and memory_check.dart talks to dart:ffi directly on purpose. |
| @@ -13,6 +13,7 @@ analyzer: | |||
| 13 | exclude: | 13 | exclude: |
| 14 | - build/** | 14 | - build/** |
| 15 | - linux/** | 15 | - linux/** |
| 16 | + - web/** | ||
| 16 | errors: | 17 | errors: |
| 17 | # The headless scripts in lib/ are command-line tools whose entire output | 18 | # The headless scripts in lib/ are command-line tools whose entire output |
| 18 | # is stdout, and memory_check.dart talks to dart:ffi directly on purpose. | 19 | # is stdout, and memory_check.dart talks to dart:ffi directly on purpose. |
modified
example/lib/main.dart +83 -16 | @@ -61,12 +61,40 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 61 | 61 | int _lastMs = 0; |
| 62 | 62 | int _tiles = 16; |
| 63 | 63 | _Benchmark? _benchmark; |
| 64 | + String? _bootError; | |
| 65 | + | |
| 66 | + /// Bands run on isolates natively and sequentially on web, so the word the | |
| 67 | + /// UI uses for them has to follow the backend. | |
| 68 | + String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band'; | |
| 64 | 69 | |
| 65 | 70 | @override |
| 66 | 71 | void initState() { |
| 67 | 72 | super.initState(); |
| 68 | - v.ensureInitialized(); | |
| 69 | - _render(); | |
| 73 | + _boot(); | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Brings the bridge up and draws the first frame. | |
| 77 | + Future<void> _boot() => _render(); | |
| 78 | + | |
| 79 | + /// Waits for the bridge, returning false if it could not be brought up. | |
| 80 | + /// | |
| 81 | + /// On native this resolves immediately. On web it is where the wasm module | |
| 82 | + /// is fetched and instantiated, which cannot be synchronous. | |
| 83 | + /// | |
| 84 | + /// Every path that asks for a frame goes through here, not just startup: | |
| 85 | + /// the first layout pass calls _fitToCanvas -> _render before initState's | |
| 86 | + /// future has resolved, so gating only the boot path would still let the | |
| 87 | + /// very first render race the module load. initialize() is idempotent and | |
| 88 | + /// hands back the in-flight future, so the extra calls just wait. | |
| 89 | + Future<bool> _ensureReady() async { | |
| 90 | + if (v.isInitialized) return true; | |
| 91 | + try { | |
| 92 | + await v.initialize(); | |
| 93 | + return true; | |
| 94 | + } catch (e) { | |
| 95 | + if (mounted) setState(() => _bootError = '$e'); | |
| 96 | + return false; | |
| 97 | + } | |
| 70 | 98 | } |
| 71 | 99 | |
| 72 | 100 | @override |
| @@ -81,6 +109,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 81 | 109 | /// outright would leave the view mutated but never drawn, so instead the |
| 82 | 110 | /// latest request is remembered and serviced once the in-flight frame lands. |
| 83 | 111 | Future<void> _render() async { |
| 112 | + if (!await _ensureReady()) return; | |
| 84 | 113 | if (_rendering) { |
| 85 | 114 | _renderQueued = true; |
| 86 | 115 | return; |
| @@ -166,6 +195,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 166 | 195 | /// Renders the identical frame in V and in Dart on this isolate, so the |
| 167 | 196 | /// comparison is like-for-like rather than V-plus-parallelism vs Dart. |
| 168 | 197 | Future<void> _runBenchmark() async { |
| 198 | + if (!await _ensureReady()) return; | |
| 169 | 199 | setState(() { |
| 170 | 200 | _rendering = true; |
| 171 | 201 | _benchmark = null; |
| @@ -187,6 +217,8 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 187 | 217 | setState(() { |
| 188 | 218 | _rendering = false; |
| 189 | 219 | _benchmark = _Benchmark( |
| 220 | + unit: _unit, | |
| 221 | + parallel: v.supportsIsolateParallelism, | |
| 190 | 222 | vSerialMs: swV.elapsedMilliseconds, |
| 191 | 223 | vParallelMs: swP.elapsedMilliseconds, |
| 192 | 224 | dartMs: swD.elapsedMilliseconds, |
| @@ -273,7 +305,20 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 273 | 305 | child: Stack( |
| 274 | 306 | fit: StackFit.expand, |
| 275 | 307 | children: [ |
| 276 | - if (_image != null) | |
| 308 | + if (_bootError != null) | |
| 309 | + // Most likely on web: the wasm glue did not load, so there is | |
| 310 | + // no V to call. Say so rather than spinning forever. | |
| 311 | + Center( | |
| 312 | + child: Padding( | |
| 313 | + padding: const EdgeInsets.all(24), | |
| 314 | + child: Text( | |
| 315 | + 'Could not start the V bridge.\n\n$_bootError', | |
| 316 | + textAlign: TextAlign.center, | |
| 317 | + style: Theme.of(context).textTheme.bodySmall, | |
| 318 | + ), | |
| 319 | + ), | |
| 320 | + ) | |
| 321 | + else if (_image != null) | |
| 277 | 322 | RawImage(image: _image, fit: BoxFit.fill) |
| 278 | 323 | else |
| 279 | 324 | const Center(child: CircularProgressIndicator()), |
| @@ -283,7 +328,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 283 | 328 | child: _Chip( |
| 284 | 329 | 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · ' |
| 285 | 330 | '$_lastMs ms · ' |
| 286 | - '${_tiles > 1 ? "$_tiles isolates" : "1 isolate"}', | |
| 331 | + '${_tiles > 1 ? "$_tiles ${_unit}s" : "1 $_unit"}', | |
| 287 | 332 | ), |
| 288 | 333 | ), |
| 289 | 334 | if (_rendering) |
| @@ -338,7 +383,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 338 | 383 | ), |
| 339 | 384 | ), |
| 340 | 385 | _Labelled( |
| 341 | - label: 'isolates', | |
| 386 | + label: '${_unit}s', | |
| 342 | 387 | value: '$_tiles', |
| 343 | 388 | child: Slider( |
| 344 | 389 | value: _tiles.toDouble(), |
| @@ -381,10 +426,18 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 381 | 426 | child: Padding( |
| 382 | 427 | padding: const EdgeInsets.all(14), |
| 383 | 428 | child: Text( |
| 384 | - 'The V kernel fills a buffer Dart allocates, so no V memory ' | |
| 385 | - 'crosses the boundary and there is nothing to free. Bands are ' | |
| 386 | - 'independent, which is what makes the isolate slider safe: ' | |
| 387 | - '-gc none means no stop-the-world phase to serialise them.', | |
| 429 | + v.supportsIsolateParallelism | |
| 430 | + ? 'The V kernel fills a buffer Dart allocates, so no V ' | |
| 431 | + 'memory crosses the boundary and there is nothing to ' | |
| 432 | + 'free. Bands are independent, which is what makes the ' | |
| 433 | + 'isolate slider safe: -gc none means no stop-the-world ' | |
| 434 | + 'phase to serialise them.' | |
| 435 | + : 'The same V kernel, compiled to WebAssembly and reached ' | |
| 436 | + 'over dart:js_interop instead of dart:ffi — it fills a ' | |
| 437 | + 'buffer in the wasm heap, so nothing needs freeing on ' | |
| 438 | + 'the Dart side. The module is single-threaded, so the ' | |
| 439 | + 'bands here run in sequence; they still produce the ' | |
| 440 | + 'identical frame, byte for byte, as the native build.', | |
| 388 | 441 | style: theme.textTheme.bodySmall, |
| 389 | 442 | ), |
| 390 | 443 | ), |
| @@ -401,12 +454,20 @@ class _Benchmark { | ||
| 401 | 454 | required this.vParallelMs, |
| 402 | 455 | required this.dartMs, |
| 403 | 456 | required this.tiles, |
| 457 | + required this.unit, | |
| 458 | + required this.parallel, | |
| 404 | 459 | }); |
| 405 | 460 | |
| 406 | 461 | final int vSerialMs; |
| 407 | 462 | final int vParallelMs; |
| 408 | 463 | final int dartMs; |
| 409 | 464 | final int tiles; |
| 465 | + | |
| 466 | + /// 'isolate' natively, 'band' on web. | |
| 467 | + final String unit; | |
| 468 | + | |
| 469 | + /// Whether the split actually ran concurrently. | |
| 470 | + final bool parallel; | |
| 410 | 471 | } |
| 411 | 472 | |
| 412 | 473 | class _BenchmarkCard extends StatelessWidget { |
| @@ -427,17 +488,23 @@ class _BenchmarkCard extends StatelessWidget { | ||
| 427 | 488 | Text('same frame, three ways', |
| 428 | 489 | style: theme.textTheme.titleSmall), |
| 429 | 490 | const SizedBox(height: 10), |
| 430 | - _Row(label: 'V, 1 isolate', value: '${result.vSerialMs} ms'), | |
| 491 | + _Row(label: 'V, 1 ${result.unit}', value: '${result.vSerialMs} ms'), | |
| 431 | 492 | _Row( |
| 432 | - label: 'V, ${result.tiles} isolates', | |
| 493 | + label: 'V, ${result.tiles} ${result.unit}s', | |
| 433 | 494 | value: '${result.vParallelMs} ms'), |
| 434 | - _Row(label: 'Dart, 1 isolate', value: '${result.dartMs} ms'), | |
| 495 | + _Row(label: 'Dart, 1 ${result.unit}', value: '${result.dartMs} ms'), | |
| 435 | 496 | const Divider(height: 20), |
| 436 | 497 | Text( |
| 437 | - 'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. For a ' | |
| 438 | - 'scalar FP loop the two are close — the real win here is the ' | |
| 439 | - 'parallel split, which Dart could also do. The bridge is what ' | |
| 440 | - 'this demonstrates, not a speed claim.', | |
| 498 | + result.parallel | |
| 499 | + ? 'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. ' | |
| 500 | + 'For a scalar FP loop the two are close — the real win ' | |
| 501 | + 'here is the parallel split, which Dart could also do. ' | |
| 502 | + 'The bridge is what this demonstrates, not a speed claim.' | |
| 503 | + : 'V is ${ratio.toStringAsFixed(2)}x Dart on one thread. ' | |
| 504 | + 'The wasm module is single-threaded, so the ' | |
| 505 | + '${result.tiles}-band row above is the same work done ' | |
| 506 | + 'in sequence, not in parallel — it is here to show the ' | |
| 507 | + 'split produces identical pixels either way.', | |
| 441 | 508 | style: theme.textTheme.bodySmall, |
| 442 | 509 | ), |
| 443 | 510 | ], |
| @@ -61,12 +61,40 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 61 | int _lastMs = 0; | 61 | int _lastMs = 0; |
| 62 | int _tiles = 16; | 62 | int _tiles = 16; |
| 63 | _Benchmark? _benchmark; | 63 | _Benchmark? _benchmark; |
| 64 | + String? _bootError; | ||
| 65 | + | ||
| 66 | + /// Bands run on isolates natively and sequentially on web, so the word the | ||
| 67 | + /// UI uses for them has to follow the backend. | ||
| 68 | + String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band'; | ||
| 64 | 69 | ||
| 65 | @override | 70 | @override |
| 66 | void initState() { | 71 | void initState() { |
| 67 | super.initState(); | 72 | super.initState(); |
| 68 | - v.ensureInitialized(); | 73 | + _boot(); |
| 69 | - _render(); | 74 | + } |
| 75 | + | ||
| 76 | + /// Brings the bridge up and draws the first frame. | ||
| 77 | + Future<void> _boot() => _render(); | ||
| 78 | + | ||
| 79 | + /// Waits for the bridge, returning false if it could not be brought up. | ||
| 80 | + /// | ||
| 81 | + /// On native this resolves immediately. On web it is where the wasm module | ||
| 82 | + /// is fetched and instantiated, which cannot be synchronous. | ||
| 83 | + /// | ||
| 84 | + /// Every path that asks for a frame goes through here, not just startup: | ||
| 85 | + /// the first layout pass calls _fitToCanvas -> _render before initState's | ||
| 86 | + /// future has resolved, so gating only the boot path would still let the | ||
| 87 | + /// very first render race the module load. initialize() is idempotent and | ||
| 88 | + /// hands back the in-flight future, so the extra calls just wait. | ||
| 89 | + Future<bool> _ensureReady() async { | ||
| 90 | + if (v.isInitialized) return true; | ||
| 91 | + try { | ||
| 92 | + await v.initialize(); | ||
| 93 | + return true; | ||
| 94 | + } catch (e) { | ||
| 95 | + if (mounted) setState(() => _bootError = '$e'); | ||
| 96 | + return false; | ||
| 97 | + } | ||
| 70 | } | 98 | } |
| 71 | 99 | ||
| 72 | @override | 100 | @override |
| @@ -81,6 +109,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 81 | /// outright would leave the view mutated but never drawn, so instead the | 109 | /// outright would leave the view mutated but never drawn, so instead the |
| 82 | /// latest request is remembered and serviced once the in-flight frame lands. | 110 | /// latest request is remembered and serviced once the in-flight frame lands. |
| 83 | Future<void> _render() async { | 111 | Future<void> _render() async { |
| 112 | + if (!await _ensureReady()) return; | ||
| 84 | if (_rendering) { | 113 | if (_rendering) { |
| 85 | _renderQueued = true; | 114 | _renderQueued = true; |
| 86 | return; | 115 | return; |
| @@ -166,6 +195,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 166 | /// Renders the identical frame in V and in Dart on this isolate, so the | 195 | /// Renders the identical frame in V and in Dart on this isolate, so the |
| 167 | /// comparison is like-for-like rather than V-plus-parallelism vs Dart. | 196 | /// comparison is like-for-like rather than V-plus-parallelism vs Dart. |
| 168 | Future<void> _runBenchmark() async { | 197 | Future<void> _runBenchmark() async { |
| 198 | + if (!await _ensureReady()) return; | ||
| 169 | setState(() { | 199 | setState(() { |
| 170 | _rendering = true; | 200 | _rendering = true; |
| 171 | _benchmark = null; | 201 | _benchmark = null; |
| @@ -187,6 +217,8 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 187 | setState(() { | 217 | setState(() { |
| 188 | _rendering = false; | 218 | _rendering = false; |
| 189 | _benchmark = _Benchmark( | 219 | _benchmark = _Benchmark( |
| 220 | + unit: _unit, | ||
| 221 | + parallel: v.supportsIsolateParallelism, | ||
| 190 | vSerialMs: swV.elapsedMilliseconds, | 222 | vSerialMs: swV.elapsedMilliseconds, |
| 191 | vParallelMs: swP.elapsedMilliseconds, | 223 | vParallelMs: swP.elapsedMilliseconds, |
| 192 | dartMs: swD.elapsedMilliseconds, | 224 | dartMs: swD.elapsedMilliseconds, |
| @@ -273,7 +305,20 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 273 | child: Stack( | 305 | child: Stack( |
| 274 | fit: StackFit.expand, | 306 | fit: StackFit.expand, |
| 275 | children: [ | 307 | children: [ |
| 276 | - if (_image != null) | 308 | + if (_bootError != null) |
| 309 | + // Most likely on web: the wasm glue did not load, so there is | ||
| 310 | + // no V to call. Say so rather than spinning forever. | ||
| 311 | + Center( | ||
| 312 | + child: Padding( | ||
| 313 | + padding: const EdgeInsets.all(24), | ||
| 314 | + child: Text( | ||
| 315 | + 'Could not start the V bridge.\n\n$_bootError', | ||
| 316 | + textAlign: TextAlign.center, | ||
| 317 | + style: Theme.of(context).textTheme.bodySmall, | ||
| 318 | + ), | ||
| 319 | + ), | ||
| 320 | + ) | ||
| 321 | + else if (_image != null) | ||
| 277 | RawImage(image: _image, fit: BoxFit.fill) | 322 | RawImage(image: _image, fit: BoxFit.fill) |
| 278 | else | 323 | else |
| 279 | const Center(child: CircularProgressIndicator()), | 324 | const Center(child: CircularProgressIndicator()), |
| @@ -283,7 +328,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 283 | child: _Chip( | 328 | child: _Chip( |
| 284 | 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · ' | 329 | 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · ' |
| 285 | '$_lastMs ms · ' | 330 | '$_lastMs ms · ' |
| 286 | - '${_tiles > 1 ? "$_tiles isolates" : "1 isolate"}', | 331 | + '${_tiles > 1 ? "$_tiles ${_unit}s" : "1 $_unit"}', |
| 287 | ), | 332 | ), |
| 288 | ), | 333 | ), |
| 289 | if (_rendering) | 334 | if (_rendering) |
| @@ -338,7 +383,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 338 | ), | 383 | ), |
| 339 | ), | 384 | ), |
| 340 | _Labelled( | 385 | _Labelled( |
| 341 | - label: 'isolates', | 386 | + label: '${_unit}s', |
| 342 | value: '$_tiles', | 387 | value: '$_tiles', |
| 343 | child: Slider( | 388 | child: Slider( |
| 344 | value: _tiles.toDouble(), | 389 | value: _tiles.toDouble(), |
| @@ -381,10 +426,18 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 381 | child: Padding( | 426 | child: Padding( |
| 382 | padding: const EdgeInsets.all(14), | 427 | padding: const EdgeInsets.all(14), |
| 383 | child: Text( | 428 | child: Text( |
| 384 | - 'The V kernel fills a buffer Dart allocates, so no V memory ' | 429 | + v.supportsIsolateParallelism |
| 385 | - 'crosses the boundary and there is nothing to free. Bands are ' | 430 | + ? 'The V kernel fills a buffer Dart allocates, so no V ' |
| 386 | - 'independent, which is what makes the isolate slider safe: ' | 431 | + 'memory crosses the boundary and there is nothing to ' |
| 387 | - '-gc none means no stop-the-world phase to serialise them.', | 432 | + 'free. Bands are independent, which is what makes the ' |
| 433 | + 'isolate slider safe: -gc none means no stop-the-world ' | ||
| 434 | + 'phase to serialise them.' | ||
| 435 | + : 'The same V kernel, compiled to WebAssembly and reached ' | ||
| 436 | + 'over dart:js_interop instead of dart:ffi — it fills a ' | ||
| 437 | + 'buffer in the wasm heap, so nothing needs freeing on ' | ||
| 438 | + 'the Dart side. The module is single-threaded, so the ' | ||
| 439 | + 'bands here run in sequence; they still produce the ' | ||
| 440 | + 'identical frame, byte for byte, as the native build.', | ||
| 388 | style: theme.textTheme.bodySmall, | 441 | style: theme.textTheme.bodySmall, |
| 389 | ), | 442 | ), |
| 390 | ), | 443 | ), |
| @@ -401,12 +454,20 @@ class _Benchmark { | |||
| 401 | required this.vParallelMs, | 454 | required this.vParallelMs, |
| 402 | required this.dartMs, | 455 | required this.dartMs, |
| 403 | required this.tiles, | 456 | required this.tiles, |
| 457 | + required this.unit, | ||
| 458 | + required this.parallel, | ||
| 404 | }); | 459 | }); |
| 405 | 460 | ||
| 406 | final int vSerialMs; | 461 | final int vSerialMs; |
| 407 | final int vParallelMs; | 462 | final int vParallelMs; |
| 408 | final int dartMs; | 463 | final int dartMs; |
| 409 | final int tiles; | 464 | final int tiles; |
| 465 | + | ||
| 466 | + /// 'isolate' natively, 'band' on web. | ||
| 467 | + final String unit; | ||
| 468 | + | ||
| 469 | + /// Whether the split actually ran concurrently. | ||
| 470 | + final bool parallel; | ||
| 410 | } | 471 | } |
| 411 | 472 | ||
| 412 | class _BenchmarkCard extends StatelessWidget { | 473 | class _BenchmarkCard extends StatelessWidget { |
| @@ -427,17 +488,23 @@ class _BenchmarkCard extends StatelessWidget { | |||
| 427 | Text('same frame, three ways', | 488 | Text('same frame, three ways', |
| 428 | style: theme.textTheme.titleSmall), | 489 | style: theme.textTheme.titleSmall), |
| 429 | const SizedBox(height: 10), | 490 | const SizedBox(height: 10), |
| 430 | - _Row(label: 'V, 1 isolate', value: '${result.vSerialMs} ms'), | 491 | + _Row(label: 'V, 1 ${result.unit}', value: '${result.vSerialMs} ms'), |
| 431 | _Row( | 492 | _Row( |
| 432 | - label: 'V, ${result.tiles} isolates', | 493 | + label: 'V, ${result.tiles} ${result.unit}s', |
| 433 | value: '${result.vParallelMs} ms'), | 494 | value: '${result.vParallelMs} ms'), |
| 434 | - _Row(label: 'Dart, 1 isolate', value: '${result.dartMs} ms'), | 495 | + _Row(label: 'Dart, 1 ${result.unit}', value: '${result.dartMs} ms'), |
| 435 | const Divider(height: 20), | 496 | const Divider(height: 20), |
| 436 | Text( | 497 | Text( |
| 437 | - 'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. For a ' | 498 | + result.parallel |
| 438 | - 'scalar FP loop the two are close — the real win here is the ' | 499 | + ? 'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. ' |
| 439 | - 'parallel split, which Dart could also do. The bridge is what ' | 500 | + 'For a scalar FP loop the two are close — the real win ' |
| 440 | - 'this demonstrates, not a speed claim.', | 501 | + 'here is the parallel split, which Dart could also do. ' |
| 502 | + 'The bridge is what this demonstrates, not a speed claim.' | ||
| 503 | + : 'V is ${ratio.toStringAsFixed(2)}x Dart on one thread. ' | ||
| 504 | + 'The wasm module is single-threaded, so the ' | ||
| 505 | + '${result.tiles}-band row above is the same work done ' | ||
| 506 | + 'in sequence, not in parallel — it is here to show the ' | ||
| 507 | + 'split produces identical pixels either way.', | ||
| 441 | style: theme.textTheme.bodySmall, | 508 | style: theme.textTheme.bodySmall, |
| 442 | ), | 509 | ), |
| 443 | ], | 510 | ], |
added
example/test/abi_guard_test.dart +107 -0 | new file mode 100644 | ||
| @@ -0,0 +1,107 @@ | ||
| 1 | +// Guards the vf_mandelbrot contract at the C boundary. | |
| 2 | +// | |
| 3 | +// Everything else in the suite goes through lib/vflutter_ffi.dart, which | |
| 4 | +// always passes a correctly sized buffer. These tests deliberately bypass it | |
| 5 | +// and call the raw symbol the way a careless C caller would, because the | |
| 6 | +// point of `buf_len` is to survive exactly that. Needs the native build on | |
| 7 | +// the loader path — see widget_test.dart. | |
| 8 | +import 'dart:ffi'; | |
| 9 | + | |
| 10 | +import 'package:ffi/ffi.dart'; | |
| 11 | +import 'package:flutter_test/flutter_test.dart'; | |
| 12 | +import 'package:vflutter_ffi/vflutter_ffi.dart' as v; | |
| 13 | + | |
| 14 | +typedef _MandelbrotNative = Void Function(Pointer<Uint8>, Size, Int32, Int32, | |
| 15 | + Double, Double, Double, Int32, Int32, Int32); | |
| 16 | +typedef _MandelbrotDart = void Function(Pointer<Uint8>, int, int, int, double, | |
| 17 | + double, double, int, int, int); | |
| 18 | + | |
| 19 | +void main() { | |
| 20 | + v.ensureInitialized(); | |
| 21 | + | |
| 22 | + final mandelbrot = DynamicLibrary.open('libvflutter.so') | |
| 23 | + .lookupFunction<_MandelbrotNative, _MandelbrotDart>('vf_mandelbrot'); | |
| 24 | + | |
| 25 | + const w = 16; | |
| 26 | + const h = 16; | |
| 27 | + const bytes = w * h * 4; | |
| 28 | + // A canary region past the band. Nothing below may touch it. | |
| 29 | + const canary = 1024; | |
| 30 | + const canaryByte = 0xAB; | |
| 31 | + | |
| 32 | + late Pointer<Uint8> buf; | |
| 33 | + | |
| 34 | + setUp(() { | |
| 35 | + buf = calloc<Uint8>(bytes + canary); | |
| 36 | + for (var i = bytes; i < bytes + canary; i++) { | |
| 37 | + buf[i] = canaryByte; | |
| 38 | + } | |
| 39 | + }); | |
| 40 | + | |
| 41 | + tearDown(() => calloc.free(buf)); | |
| 42 | + | |
| 43 | + bool canaryIntact() { | |
| 44 | + for (var i = bytes; i < bytes + canary; i++) { | |
| 45 | + if (buf[i] != canaryByte) return false; | |
| 46 | + } | |
| 47 | + return true; | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// True if V wrote anything into the band at all. | |
| 51 | + bool bandTouched() { | |
| 52 | + for (var i = 0; i < bytes; i++) { | |
| 53 | + if (buf[i] != 0) return true; | |
| 54 | + } | |
| 55 | + return false; | |
| 56 | + } | |
| 57 | + | |
| 58 | + void expectNoOp(String why) { | |
| 59 | + expect(bandTouched(), isFalse, reason: '$why: V wrote into the band'); | |
| 60 | + expect(canaryIntact(), isTrue, reason: '$why: V wrote past the band'); | |
| 61 | + } | |
| 62 | + | |
| 63 | + test('a correctly sized call fills the whole band', () { | |
| 64 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, h); | |
| 65 | + for (var i = 3; i < bytes; i += 4) { | |
| 66 | + expect(buf[i], 255, reason: 'alpha at pixel ${i ~/ 4}'); | |
| 67 | + } | |
| 68 | + expect(canaryIntact(), isTrue); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + test('a buf_len one byte short is refused outright', () { | |
| 72 | + // The tempting bug is to fill what fits and overrun by one; this must | |
| 73 | + // write nothing at all. | |
| 74 | + mandelbrot(buf, bytes - 1, w, h, -0.5, 0.0, 3.0, 50, 0, h); | |
| 75 | + expectNoOp('buf_len short by one'); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + test('a buf_len sized for a single row is refused', () { | |
| 79 | + mandelbrot(buf, w * 4, w, h, -0.5, 0.0, 3.0, 50, 0, h); | |
| 80 | + expectNoOp('buf_len sized for one row'); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + test('a zero buf_len is refused', () { | |
| 84 | + mandelbrot(buf, 0, w, h, -0.5, 0.0, 3.0, 50, 0, h); | |
| 85 | + expectNoOp('buf_len zero'); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + test('a null buffer is a no-op, not a crash', () { | |
| 89 | + mandelbrot(nullptr, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, h); | |
| 90 | + expectNoOp('null buf'); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + test('an inverted band is refused', () { | |
| 94 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 10, 2); | |
| 95 | + expectNoOp('y1 < y0'); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + test('geometry whose byte count overflows int32 is refused', () { | |
| 99 | + // (y1 - y0) * w * 4 = 2 * 2^30 * 4 wraps to 0 in 32-bit arithmetic, which | |
| 100 | + // is the case a length check alone would happily accept. | |
| 101 | + mandelbrot(buf, bytes, 1 << 30, h, -0.5, 0.0, 3.0, 50, 0, 2); | |
| 102 | + expectNoOp('w = 1 << 30'); | |
| 103 | + | |
| 104 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, 0x7FFFFFFF); | |
| 105 | + expectNoOp('y1 = INT32_MAX'); | |
| 106 | + }); | |
| 107 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,107 @@ | |||
| 1 | +// Guards the vf_mandelbrot contract at the C boundary. | ||
| 2 | +// | ||
| 3 | +// Everything else in the suite goes through lib/vflutter_ffi.dart, which | ||
| 4 | +// always passes a correctly sized buffer. These tests deliberately bypass it | ||
| 5 | +// and call the raw symbol the way a careless C caller would, because the | ||
| 6 | +// point of `buf_len` is to survive exactly that. Needs the native build on | ||
| 7 | +// the loader path — see widget_test.dart. | ||
| 8 | +import 'dart:ffi'; | ||
| 9 | + | ||
| 10 | +import 'package:ffi/ffi.dart'; | ||
| 11 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 12 | +import 'package:vflutter_ffi/vflutter_ffi.dart' as v; | ||
| 13 | + | ||
| 14 | +typedef _MandelbrotNative = Void Function(Pointer<Uint8>, Size, Int32, Int32, | ||
| 15 | + Double, Double, Double, Int32, Int32, Int32); | ||
| 16 | +typedef _MandelbrotDart = void Function(Pointer<Uint8>, int, int, int, double, | ||
| 17 | + double, double, int, int, int); | ||
| 18 | + | ||
| 19 | +void main() { | ||
| 20 | + v.ensureInitialized(); | ||
| 21 | + | ||
| 22 | + final mandelbrot = DynamicLibrary.open('libvflutter.so') | ||
| 23 | + .lookupFunction<_MandelbrotNative, _MandelbrotDart>('vf_mandelbrot'); | ||
| 24 | + | ||
| 25 | + const w = 16; | ||
| 26 | + const h = 16; | ||
| 27 | + const bytes = w * h * 4; | ||
| 28 | + // A canary region past the band. Nothing below may touch it. | ||
| 29 | + const canary = 1024; | ||
| 30 | + const canaryByte = 0xAB; | ||
| 31 | + | ||
| 32 | + late Pointer<Uint8> buf; | ||
| 33 | + | ||
| 34 | + setUp(() { | ||
| 35 | + buf = calloc<Uint8>(bytes + canary); | ||
| 36 | + for (var i = bytes; i < bytes + canary; i++) { | ||
| 37 | + buf[i] = canaryByte; | ||
| 38 | + } | ||
| 39 | + }); | ||
| 40 | + | ||
| 41 | + tearDown(() => calloc.free(buf)); | ||
| 42 | + | ||
| 43 | + bool canaryIntact() { | ||
| 44 | + for (var i = bytes; i < bytes + canary; i++) { | ||
| 45 | + if (buf[i] != canaryByte) return false; | ||
| 46 | + } | ||
| 47 | + return true; | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + /// True if V wrote anything into the band at all. | ||
| 51 | + bool bandTouched() { | ||
| 52 | + for (var i = 0; i < bytes; i++) { | ||
| 53 | + if (buf[i] != 0) return true; | ||
| 54 | + } | ||
| 55 | + return false; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + void expectNoOp(String why) { | ||
| 59 | + expect(bandTouched(), isFalse, reason: '$why: V wrote into the band'); | ||
| 60 | + expect(canaryIntact(), isTrue, reason: '$why: V wrote past the band'); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + test('a correctly sized call fills the whole band', () { | ||
| 64 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, h); | ||
| 65 | + for (var i = 3; i < bytes; i += 4) { | ||
| 66 | + expect(buf[i], 255, reason: 'alpha at pixel ${i ~/ 4}'); | ||
| 67 | + } | ||
| 68 | + expect(canaryIntact(), isTrue); | ||
| 69 | + }); | ||
| 70 | + | ||
| 71 | + test('a buf_len one byte short is refused outright', () { | ||
| 72 | + // The tempting bug is to fill what fits and overrun by one; this must | ||
| 73 | + // write nothing at all. | ||
| 74 | + mandelbrot(buf, bytes - 1, w, h, -0.5, 0.0, 3.0, 50, 0, h); | ||
| 75 | + expectNoOp('buf_len short by one'); | ||
| 76 | + }); | ||
| 77 | + | ||
| 78 | + test('a buf_len sized for a single row is refused', () { | ||
| 79 | + mandelbrot(buf, w * 4, w, h, -0.5, 0.0, 3.0, 50, 0, h); | ||
| 80 | + expectNoOp('buf_len sized for one row'); | ||
| 81 | + }); | ||
| 82 | + | ||
| 83 | + test('a zero buf_len is refused', () { | ||
| 84 | + mandelbrot(buf, 0, w, h, -0.5, 0.0, 3.0, 50, 0, h); | ||
| 85 | + expectNoOp('buf_len zero'); | ||
| 86 | + }); | ||
| 87 | + | ||
| 88 | + test('a null buffer is a no-op, not a crash', () { | ||
| 89 | + mandelbrot(nullptr, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, h); | ||
| 90 | + expectNoOp('null buf'); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + test('an inverted band is refused', () { | ||
| 94 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 10, 2); | ||
| 95 | + expectNoOp('y1 < y0'); | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + test('geometry whose byte count overflows int32 is refused', () { | ||
| 99 | + // (y1 - y0) * w * 4 = 2 * 2^30 * 4 wraps to 0 in 32-bit arithmetic, which | ||
| 100 | + // is the case a length check alone would happily accept. | ||
| 101 | + mandelbrot(buf, bytes, 1 << 30, h, -0.5, 0.0, 3.0, 50, 0, 2); | ||
| 102 | + expectNoOp('w = 1 << 30'); | ||
| 103 | + | ||
| 104 | + mandelbrot(buf, bytes, w, h, -0.5, 0.0, 3.0, 50, 0, 0x7FFFFFFF); | ||
| 105 | + expectNoOp('y1 = INT32_MAX'); | ||
| 106 | + }); | ||
| 107 | +} | ||
added
example/web/favicon.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/example/web/favicon.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/example/web/favicon.png differ | Binary files /dev/null and b/example/web/favicon.png differ | ||
added
example/web/icons/Icon-192.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/example/web/icons/Icon-192.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/example/web/icons/Icon-192.png differ | Binary files /dev/null and b/example/web/icons/Icon-192.png differ | ||
added
example/web/icons/Icon-512.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/example/web/icons/Icon-512.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/example/web/icons/Icon-512.png differ | Binary files /dev/null and b/example/web/icons/Icon-512.png differ | ||
added
example/web/icons/Icon-maskable-192.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ | Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ | ||
added
example/web/icons/Icon-maskable-512.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ | Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ | ||
added
example/web/index.html +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html> | |
| 3 | +<head> | |
| 4 | + <!-- | |
| 5 | + If you are serving your web app in a path other than the root, change the | |
| 6 | + href value below to reflect the base path you are serving from. | |
| 7 | + | |
| 8 | + The path provided below has to start and end with a slash "/" in order for | |
| 9 | + it to work correctly. | |
| 10 | + | |
| 11 | + For more details: | |
| 12 | + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | |
| 13 | + | |
| 14 | + This is a placeholder for base href that will be replaced by the value of | |
| 15 | + the `--base-href` argument provided to `flutter build`. | |
| 16 | + --> | |
| 17 | + <base href="$FLUTTER_BASE_HREF"> | |
| 18 | + | |
| 19 | + <meta charset="UTF-8"> | |
| 20 | + <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | |
| 21 | + <meta name="description" content="A Mandelbrot explorer whose pixels are computed in V, compiled to WebAssembly."> | |
| 22 | + | |
| 23 | + <!-- iOS meta tags & icons --> | |
| 24 | + <meta name="mobile-web-app-capable" content="yes"> | |
| 25 | + <meta name="apple-mobile-web-app-status-bar-style" content="black"> | |
| 26 | + <meta name="apple-mobile-web-app-title" content="vflutter_ffi_example"> | |
| 27 | + <link rel="apple-touch-icon" href="icons/Icon-192.png"> | |
| 28 | + | |
| 29 | + <!-- Favicon --> | |
| 30 | + <link rel="icon" type="image/png" href="favicon.png"/> | |
| 31 | + | |
| 32 | + <title>vflutter_ffi — V in the browser</title> | |
| 33 | + <link rel="manifest" href="manifest.json"> | |
| 34 | +</head> | |
| 35 | +<body> | |
| 36 | + <!-- | |
| 37 | + The V kernel, compiled to WebAssembly by tool/build_wasm.sh (`just wasm`). | |
| 38 | + Loading it defines the global createVFlutterModule, which | |
| 39 | + lib/src/backend_web.dart calls to instantiate the module. | |
| 40 | + | |
| 41 | + Deliberately not `async`: the factory has to exist before Dart's | |
| 42 | + initialize() runs. The file is ~12 KB of glue next to a ~30 KB .wasm, and | |
| 43 | + the wasm itself is still fetched lazily by the factory, so this costs | |
| 44 | + essentially nothing up front. | |
| 45 | + --> | |
| 46 | + <script src="vflutter.js"></script> | |
| 47 | + | |
| 48 | + <!-- | |
| 49 | + You can customize the "flutter_bootstrap.js" script. | |
| 50 | + This is useful to provide a custom configuration to the Flutter loader | |
| 51 | + or to give the user feedback during the initialization process. | |
| 52 | + | |
| 53 | + For more details: | |
| 54 | + * https://docs.flutter.dev/platform-integration/web/initialization | |
| 55 | + --> | |
| 56 | + <script src="flutter_bootstrap.js" async></script> | |
| 57 | +</body> | |
| 58 | +</html> | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html> | ||
| 3 | +<head> | ||
| 4 | + <!-- | ||
| 5 | + If you are serving your web app in a path other than the root, change the | ||
| 6 | + href value below to reflect the base path you are serving from. | ||
| 7 | + | ||
| 8 | + The path provided below has to start and end with a slash "/" in order for | ||
| 9 | + it to work correctly. | ||
| 10 | + | ||
| 11 | + For more details: | ||
| 12 | + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | ||
| 13 | + | ||
| 14 | + This is a placeholder for base href that will be replaced by the value of | ||
| 15 | + the `--base-href` argument provided to `flutter build`. | ||
| 16 | + --> | ||
| 17 | + <base href="$FLUTTER_BASE_HREF"> | ||
| 18 | + | ||
| 19 | + <meta charset="UTF-8"> | ||
| 20 | + <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||
| 21 | + <meta name="description" content="A Mandelbrot explorer whose pixels are computed in V, compiled to WebAssembly."> | ||
| 22 | + | ||
| 23 | + <!-- iOS meta tags & icons --> | ||
| 24 | + <meta name="mobile-web-app-capable" content="yes"> | ||
| 25 | + <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||
| 26 | + <meta name="apple-mobile-web-app-title" content="vflutter_ffi_example"> | ||
| 27 | + <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||
| 28 | + | ||
| 29 | + <!-- Favicon --> | ||
| 30 | + <link rel="icon" type="image/png" href="favicon.png"/> | ||
| 31 | + | ||
| 32 | + <title>vflutter_ffi — V in the browser</title> | ||
| 33 | + <link rel="manifest" href="manifest.json"> | ||
| 34 | +</head> | ||
| 35 | +<body> | ||
| 36 | + <!-- | ||
| 37 | + The V kernel, compiled to WebAssembly by tool/build_wasm.sh (`just wasm`). | ||
| 38 | + Loading it defines the global createVFlutterModule, which | ||
| 39 | + lib/src/backend_web.dart calls to instantiate the module. | ||
| 40 | + | ||
| 41 | + Deliberately not `async`: the factory has to exist before Dart's | ||
| 42 | + initialize() runs. The file is ~12 KB of glue next to a ~30 KB .wasm, and | ||
| 43 | + the wasm itself is still fetched lazily by the factory, so this costs | ||
| 44 | + essentially nothing up front. | ||
| 45 | + --> | ||
| 46 | + <script src="vflutter.js"></script> | ||
| 47 | + | ||
| 48 | + <!-- | ||
| 49 | + You can customize the "flutter_bootstrap.js" script. | ||
| 50 | + This is useful to provide a custom configuration to the Flutter loader | ||
| 51 | + or to give the user feedback during the initialization process. | ||
| 52 | + | ||
| 53 | + For more details: | ||
| 54 | + * https://docs.flutter.dev/platform-integration/web/initialization | ||
| 55 | + --> | ||
| 56 | + <script src="flutter_bootstrap.js" async></script> | ||
| 57 | +</body> | ||
| 58 | +</html> | ||
added
example/web/manifest.json +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +{ | |
| 2 | + "name": "vflutter_ffi_example", | |
| 3 | + "short_name": "vflutter_ffi_example", | |
| 4 | + "start_url": ".", | |
| 5 | + "display": "standalone", | |
| 6 | + "background_color": "#0175C2", | |
| 7 | + "theme_color": "#0175C2", | |
| 8 | + "description": "A new Flutter project.", | |
| 9 | + "orientation": "portrait-primary", | |
| 10 | + "prefer_related_applications": false, | |
| 11 | + "icons": [ | |
| 12 | + { | |
| 13 | + "src": "icons/Icon-192.png", | |
| 14 | + "sizes": "192x192", | |
| 15 | + "type": "image/png" | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + "src": "icons/Icon-512.png", | |
| 19 | + "sizes": "512x512", | |
| 20 | + "type": "image/png" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "src": "icons/Icon-maskable-192.png", | |
| 24 | + "sizes": "192x192", | |
| 25 | + "type": "image/png", | |
| 26 | + "purpose": "maskable" | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + "src": "icons/Icon-maskable-512.png", | |
| 30 | + "sizes": "512x512", | |
| 31 | + "type": "image/png", | |
| 32 | + "purpose": "maskable" | |
| 33 | + } | |
| 34 | + ] | |
| 35 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "vflutter_ffi_example", | ||
| 3 | + "short_name": "vflutter_ffi_example", | ||
| 4 | + "start_url": ".", | ||
| 5 | + "display": "standalone", | ||
| 6 | + "background_color": "#0175C2", | ||
| 7 | + "theme_color": "#0175C2", | ||
| 8 | + "description": "A new Flutter project.", | ||
| 9 | + "orientation": "portrait-primary", | ||
| 10 | + "prefer_related_applications": false, | ||
| 11 | + "icons": [ | ||
| 12 | + { | ||
| 13 | + "src": "icons/Icon-192.png", | ||
| 14 | + "sizes": "192x192", | ||
| 15 | + "type": "image/png" | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + "src": "icons/Icon-512.png", | ||
| 19 | + "sizes": "512x512", | ||
| 20 | + "type": "image/png" | ||
| 21 | + }, | ||
| 22 | + { | ||
| 23 | + "src": "icons/Icon-maskable-192.png", | ||
| 24 | + "sizes": "192x192", | ||
| 25 | + "type": "image/png", | ||
| 26 | + "purpose": "maskable" | ||
| 27 | + }, | ||
| 28 | + { | ||
| 29 | + "src": "icons/Icon-maskable-512.png", | ||
| 30 | + "sizes": "512x512", | ||
| 31 | + "type": "image/png", | ||
| 32 | + "purpose": "maskable" | ||
| 33 | + } | ||
| 34 | + ] | ||
| 35 | +} | ||
modified
justfile +36 -2 | @@ -7,6 +7,9 @@ flutter_bin := env_var_or_default("FLUTTER_BIN", env_var("HOME") / "flutter/bin" | ||
| 7 | 7 | flutter := flutter_bin / "flutter" |
| 8 | 8 | lib_path := justfile_directory() / "build" |
| 9 | 9 | |
| 10 | +# Port for `just web`. Override with WEB_PORT=9000 just web | |
| 11 | +web_port := env_var_or_default("WEB_PORT", "8080") | |
| 12 | + | |
| 10 | 13 | _default: |
| 11 | 14 | @just --list |
| 12 | 15 | |
| @@ -25,6 +28,37 @@ build: | ||
| 25 | 28 | gen-ios: |
| 26 | 29 | ./tool/gen_ios_sources.sh |
| 27 | 30 | |
| 31 | +# emcc is not installed: tool/bin/emcc is a DotSlash file that fetches a | |
| 32 | +# content-pinned emscripten on first run and caches it in ~/.cache/dotslash. | |
| 33 | +# Only `dotslash` itself needs to be on PATH. | |
| 34 | +# | |
| 35 | +# Compile the V source to WebAssembly for the web build. | |
| 36 | +wasm: | |
| 37 | + ./tool/build_wasm.sh | |
| 38 | + | |
| 39 | +# Headless check of the wasm module — no browser, no Flutter. | |
| 40 | +test-wasm: wasm | |
| 41 | + node tool/test_wasm.mjs | |
| 42 | + | |
| 43 | +# Same frame from the wasm and native kernels, compared byte for byte. | |
| 44 | +parity: build wasm | |
| 45 | + ./tool/check_parity.sh | |
| 46 | + | |
| 47 | +# Prints a URL and waits; no browser is launched. Hot restart still works | |
| 48 | +# (press R). Override the port with WEB_PORT=9000 just web | |
| 49 | +# | |
| 50 | +# Serve the Mandelbrot explorer, with V running as wasm. | |
| 51 | +web: wasm | |
| 52 | + cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} run -d web-server --web-port={{web_port}} | |
| 53 | + | |
| 54 | +# Release web build of the example. Output: example/build/web/ | |
| 55 | +build-web: wasm | |
| 56 | + cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build web --release | |
| 57 | + | |
| 58 | +# Load the built web app in headless Chrome and assert V drew a real frame. | |
| 59 | +check-web: build-web | |
| 60 | + ./tool/check_web.sh | |
| 61 | + | |
| 28 | 62 | # Analyze and test the example. Needs the host library for the FFI calls. |
| 29 | 63 | test: build |
| 30 | 64 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze |
| @@ -53,7 +87,7 @@ release: build | ||
| 53 | 87 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build linux --release |
| 54 | 88 | |
| 55 | 89 | # Everything CI would run. |
| 56 | -ci: build smoke memcheck test bench | |
| 90 | +ci: build smoke memcheck test bench test-wasm parity check-web | |
| 57 | 91 | |
| 58 | 92 | clean: |
| 59 | - rm -rf build example/build | |
| 93 | + rm -rf build example/build example/web/vflutter.js example/web/vflutter.wasm | |
| @@ -7,6 +7,9 @@ flutter_bin := env_var_or_default("FLUTTER_BIN", env_var("HOME") / "flutter/bin" | |||
| 7 | flutter := flutter_bin / "flutter" | 7 | flutter := flutter_bin / "flutter" |
| 8 | lib_path := justfile_directory() / "build" | 8 | lib_path := justfile_directory() / "build" |
| 9 | 9 | ||
| 10 | +# Port for `just web`. Override with WEB_PORT=9000 just web | ||
| 11 | +web_port := env_var_or_default("WEB_PORT", "8080") | ||
| 12 | + | ||
| 10 | _default: | 13 | _default: |
| 11 | @just --list | 14 | @just --list |
| 12 | 15 | ||
| @@ -25,6 +28,37 @@ build: | |||
| 25 | gen-ios: | 28 | gen-ios: |
| 26 | ./tool/gen_ios_sources.sh | 29 | ./tool/gen_ios_sources.sh |
| 27 | 30 | ||
| 31 | +# emcc is not installed: tool/bin/emcc is a DotSlash file that fetches a | ||
| 32 | +# content-pinned emscripten on first run and caches it in ~/.cache/dotslash. | ||
| 33 | +# Only `dotslash` itself needs to be on PATH. | ||
| 34 | +# | ||
| 35 | +# Compile the V source to WebAssembly for the web build. | ||
| 36 | +wasm: | ||
| 37 | + ./tool/build_wasm.sh | ||
| 38 | + | ||
| 39 | +# Headless check of the wasm module — no browser, no Flutter. | ||
| 40 | +test-wasm: wasm | ||
| 41 | + node tool/test_wasm.mjs | ||
| 42 | + | ||
| 43 | +# Same frame from the wasm and native kernels, compared byte for byte. | ||
| 44 | +parity: build wasm | ||
| 45 | + ./tool/check_parity.sh | ||
| 46 | + | ||
| 47 | +# Prints a URL and waits; no browser is launched. Hot restart still works | ||
| 48 | +# (press R). Override the port with WEB_PORT=9000 just web | ||
| 49 | +# | ||
| 50 | +# Serve the Mandelbrot explorer, with V running as wasm. | ||
| 51 | +web: wasm | ||
| 52 | + cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} run -d web-server --web-port={{web_port}} | ||
| 53 | + | ||
| 54 | +# Release web build of the example. Output: example/build/web/ | ||
| 55 | +build-web: wasm | ||
| 56 | + cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build web --release | ||
| 57 | + | ||
| 58 | +# Load the built web app in headless Chrome and assert V drew a real frame. | ||
| 59 | +check-web: build-web | ||
| 60 | + ./tool/check_web.sh | ||
| 61 | + | ||
| 28 | # Analyze and test the example. Needs the host library for the FFI calls. | 62 | # Analyze and test the example. Needs the host library for the FFI calls. |
| 29 | test: build | 63 | test: build |
| 30 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze | 64 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze |
| @@ -53,7 +87,7 @@ release: build | |||
| 53 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build linux --release | 87 | cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build linux --release |
| 54 | 88 | ||
| 55 | # Everything CI would run. | 89 | # Everything CI would run. |
| 56 | -ci: build smoke memcheck test bench | 90 | +ci: build smoke memcheck test bench test-wasm parity check-web |
| 57 | 91 | ||
| 58 | clean: | 92 | clean: |
| 59 | - rm -rf build example/build | 93 | + rm -rf build example/build example/web/vflutter.js example/web/vflutter.wasm |
added
lib/src/backend_native.dart +141 -0 | new file mode 100644 | ||
| @@ -0,0 +1,141 @@ | ||
| 1 | +/// Native backend: the V library as a shared object / static archive, reached | |
| 2 | +/// through `dart:ffi`. | |
| 3 | +/// | |
| 4 | +/// Selected by the conditional import in `../vflutter_ffi.dart` on every | |
| 5 | +/// target except web. The web twin is `backend_web.dart`; the two files must | |
| 6 | +/// keep the same top-level signatures. | |
| 7 | +library; | |
| 8 | + | |
| 9 | +import 'dart:ffi'; | |
| 10 | +import 'dart:io'; | |
| 11 | +import 'dart:isolate'; | |
| 12 | +import 'dart:typed_data'; | |
| 13 | + | |
| 14 | +import 'package:ffi/ffi.dart'; | |
| 15 | + | |
| 16 | +import 'fractal_view.dart'; | |
| 17 | + | |
| 18 | +const String backendName = 'dart:ffi'; | |
| 19 | + | |
| 20 | +/// Isolates are real threads here, and `-gc none` means there is no | |
| 21 | +/// stop-the-world phase to serialise them. | |
| 22 | +const bool supportsIsolateParallelism = true; | |
| 23 | + | |
| 24 | +const String _libName = 'vflutter'; | |
| 25 | + | |
| 26 | +DynamicLibrary _open() { | |
| 27 | + if (Platform.isMacOS || Platform.isIOS) { | |
| 28 | + // Static archive linked into the app binary. | |
| 29 | + return DynamicLibrary.process(); | |
| 30 | + } | |
| 31 | + if (Platform.isAndroid || Platform.isLinux) { | |
| 32 | + return DynamicLibrary.open('lib$_libName.so'); | |
| 33 | + } | |
| 34 | + if (Platform.isWindows) { | |
| 35 | + return DynamicLibrary.open('$_libName.dll'); | |
| 36 | + } | |
| 37 | + throw UnsupportedError('vflutter_ffi: unsupported platform'); | |
| 38 | +} | |
| 39 | + | |
| 40 | +final DynamicLibrary _lib = _open(); | |
| 41 | + | |
| 42 | +final void Function() _vfInit = | |
| 43 | + _lib.lookupFunction<Void Function(), void Function()>('vf_init'); | |
| 44 | + | |
| 45 | +final int Function(int, int) _vfAdd = | |
| 46 | + _lib.lookupFunction<Int32 Function(Int32, Int32), int Function(int, int)>( | |
| 47 | + 'vf_add'); | |
| 48 | + | |
| 49 | +final Pointer<Utf8> Function(Pointer<Utf8>) _vfGreet = _lib.lookupFunction< | |
| 50 | + Pointer<Utf8> Function(Pointer<Utf8>), | |
| 51 | + Pointer<Utf8> Function(Pointer<Utf8>)>('vf_greet'); | |
| 52 | + | |
| 53 | +final void Function(Pointer<Void>) _vfFree = _lib.lookupFunction< | |
| 54 | + Void Function(Pointer<Void>), void Function(Pointer<Void>)>('vf_free'); | |
| 55 | + | |
| 56 | +final void Function(Pointer<Uint8>, int, int, int, double, double, double, int, | |
| 57 | + int, int) _vfMandelbrot = _lib.lookupFunction< | |
| 58 | + Void Function(Pointer<Uint8>, Size, Int32, Int32, Double, Double, | |
| 59 | + Double, Int32, Int32, Int32), | |
| 60 | + void Function(Pointer<Uint8>, int, int, int, double, double, double, | |
| 61 | + int, int, int)>('vf_mandelbrot'); | |
| 62 | + | |
| 63 | +bool _ready = false; | |
| 64 | + | |
| 65 | +bool get isInitialized => _ready; | |
| 66 | + | |
| 67 | +/// Loading a shared object is synchronous, so this is only here to match the | |
| 68 | +/// web backend's contract. Nothing awaits anything. | |
| 69 | +Future<void> initialize() async => ensureInitialized(); | |
| 70 | + | |
| 71 | +void ensureInitialized() { | |
| 72 | + if (_ready) return; | |
| 73 | + _vfInit(); | |
| 74 | + _ready = true; | |
| 75 | +} | |
| 76 | + | |
| 77 | +int add(int a, int b) { | |
| 78 | + ensureInitialized(); | |
| 79 | + return _vfAdd(a, b); | |
| 80 | +} | |
| 81 | + | |
| 82 | +String greet(String name) { | |
| 83 | + ensureInitialized(); | |
| 84 | + final arg = name.toNativeUtf8(); | |
| 85 | + Pointer<Utf8> res = nullptr; | |
| 86 | + try { | |
| 87 | + res = _vfGreet(arg); | |
| 88 | + if (res == nullptr) { | |
| 89 | + throw StateError('vf_greet returned null'); | |
| 90 | + } | |
| 91 | + return res.toDartString(); | |
| 92 | + } finally { | |
| 93 | + calloc.free(arg); | |
| 94 | + if (res != nullptr) _vfFree(res.cast()); | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +Future<String> greetAsync(String name) => Isolate.run(() => greet(name)); | |
| 99 | + | |
| 100 | +Uint8List renderBand(FractalView view, int y0, int y1) { | |
| 101 | + ensureInitialized(); | |
| 102 | + final rows = y1 - y0; | |
| 103 | + if (rows <= 0) return Uint8List(0); | |
| 104 | + | |
| 105 | + final bytes = rows * view.width * 4; | |
| 106 | + final buf = calloc<Uint8>(bytes); | |
| 107 | + try { | |
| 108 | + // `bytes` is the real allocation size, not a restatement of the band: V | |
| 109 | + // checks it before writing, so an arithmetic slip here is caught there | |
| 110 | + // rather than becoming a heap overflow. | |
| 111 | + _vfMandelbrot(buf, bytes, view.width, view.height, view.centerX, | |
| 112 | + view.centerY, view.scale, view.maxIter, y0, y1); | |
| 113 | + // Copy out of native memory before it is freed. | |
| 114 | + return Uint8List.fromList(buf.asTypedList(bytes)); | |
| 115 | + } finally { | |
| 116 | + calloc.free(buf); | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +Future<Uint8List> renderParallel(FractalView view, int tiles) async { | |
| 121 | + ensureInitialized(); | |
| 122 | + final count = tiles.clamp(1, view.height); | |
| 123 | + final rowsPer = (view.height / count).ceil(); | |
| 124 | + | |
| 125 | + final futures = <Future<Uint8List>>[]; | |
| 126 | + for (var t = 0; t < count; t++) { | |
| 127 | + final y0 = t * rowsPer; | |
| 128 | + final y1 = ((t + 1) * rowsPer).clamp(0, view.height); | |
| 129 | + if (y0 >= y1) break; | |
| 130 | + futures.add(Isolate.run(() => renderBand(view, y0, y1))); | |
| 131 | + } | |
| 132 | + | |
| 133 | + final bands = await Future.wait(futures); | |
| 134 | + final out = Uint8List(view.width * view.height * 4); | |
| 135 | + var offset = 0; | |
| 136 | + for (final band in bands) { | |
| 137 | + out.setRange(offset, offset + band.length, band); | |
| 138 | + offset += band.length; | |
| 139 | + } | |
| 140 | + return out; | |
| 141 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,141 @@ | |||
| 1 | +/// Native backend: the V library as a shared object / static archive, reached | ||
| 2 | +/// through `dart:ffi`. | ||
| 3 | +/// | ||
| 4 | +/// Selected by the conditional import in `../vflutter_ffi.dart` on every | ||
| 5 | +/// target except web. The web twin is `backend_web.dart`; the two files must | ||
| 6 | +/// keep the same top-level signatures. | ||
| 7 | +library; | ||
| 8 | + | ||
| 9 | +import 'dart:ffi'; | ||
| 10 | +import 'dart:io'; | ||
| 11 | +import 'dart:isolate'; | ||
| 12 | +import 'dart:typed_data'; | ||
| 13 | + | ||
| 14 | +import 'package:ffi/ffi.dart'; | ||
| 15 | + | ||
| 16 | +import 'fractal_view.dart'; | ||
| 17 | + | ||
| 18 | +const String backendName = 'dart:ffi'; | ||
| 19 | + | ||
| 20 | +/// Isolates are real threads here, and `-gc none` means there is no | ||
| 21 | +/// stop-the-world phase to serialise them. | ||
| 22 | +const bool supportsIsolateParallelism = true; | ||
| 23 | + | ||
| 24 | +const String _libName = 'vflutter'; | ||
| 25 | + | ||
| 26 | +DynamicLibrary _open() { | ||
| 27 | + if (Platform.isMacOS || Platform.isIOS) { | ||
| 28 | + // Static archive linked into the app binary. | ||
| 29 | + return DynamicLibrary.process(); | ||
| 30 | + } | ||
| 31 | + if (Platform.isAndroid || Platform.isLinux) { | ||
| 32 | + return DynamicLibrary.open('lib$_libName.so'); | ||
| 33 | + } | ||
| 34 | + if (Platform.isWindows) { | ||
| 35 | + return DynamicLibrary.open('$_libName.dll'); | ||
| 36 | + } | ||
| 37 | + throw UnsupportedError('vflutter_ffi: unsupported platform'); | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +final DynamicLibrary _lib = _open(); | ||
| 41 | + | ||
| 42 | +final void Function() _vfInit = | ||
| 43 | + _lib.lookupFunction<Void Function(), void Function()>('vf_init'); | ||
| 44 | + | ||
| 45 | +final int Function(int, int) _vfAdd = | ||
| 46 | + _lib.lookupFunction<Int32 Function(Int32, Int32), int Function(int, int)>( | ||
| 47 | + 'vf_add'); | ||
| 48 | + | ||
| 49 | +final Pointer<Utf8> Function(Pointer<Utf8>) _vfGreet = _lib.lookupFunction< | ||
| 50 | + Pointer<Utf8> Function(Pointer<Utf8>), | ||
| 51 | + Pointer<Utf8> Function(Pointer<Utf8>)>('vf_greet'); | ||
| 52 | + | ||
| 53 | +final void Function(Pointer<Void>) _vfFree = _lib.lookupFunction< | ||
| 54 | + Void Function(Pointer<Void>), void Function(Pointer<Void>)>('vf_free'); | ||
| 55 | + | ||
| 56 | +final void Function(Pointer<Uint8>, int, int, int, double, double, double, int, | ||
| 57 | + int, int) _vfMandelbrot = _lib.lookupFunction< | ||
| 58 | + Void Function(Pointer<Uint8>, Size, Int32, Int32, Double, Double, | ||
| 59 | + Double, Int32, Int32, Int32), | ||
| 60 | + void Function(Pointer<Uint8>, int, int, int, double, double, double, | ||
| 61 | + int, int, int)>('vf_mandelbrot'); | ||
| 62 | + | ||
| 63 | +bool _ready = false; | ||
| 64 | + | ||
| 65 | +bool get isInitialized => _ready; | ||
| 66 | + | ||
| 67 | +/// Loading a shared object is synchronous, so this is only here to match the | ||
| 68 | +/// web backend's contract. Nothing awaits anything. | ||
| 69 | +Future<void> initialize() async => ensureInitialized(); | ||
| 70 | + | ||
| 71 | +void ensureInitialized() { | ||
| 72 | + if (_ready) return; | ||
| 73 | + _vfInit(); | ||
| 74 | + _ready = true; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +int add(int a, int b) { | ||
| 78 | + ensureInitialized(); | ||
| 79 | + return _vfAdd(a, b); | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +String greet(String name) { | ||
| 83 | + ensureInitialized(); | ||
| 84 | + final arg = name.toNativeUtf8(); | ||
| 85 | + Pointer<Utf8> res = nullptr; | ||
| 86 | + try { | ||
| 87 | + res = _vfGreet(arg); | ||
| 88 | + if (res == nullptr) { | ||
| 89 | + throw StateError('vf_greet returned null'); | ||
| 90 | + } | ||
| 91 | + return res.toDartString(); | ||
| 92 | + } finally { | ||
| 93 | + calloc.free(arg); | ||
| 94 | + if (res != nullptr) _vfFree(res.cast()); | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +Future<String> greetAsync(String name) => Isolate.run(() => greet(name)); | ||
| 99 | + | ||
| 100 | +Uint8List renderBand(FractalView view, int y0, int y1) { | ||
| 101 | + ensureInitialized(); | ||
| 102 | + final rows = y1 - y0; | ||
| 103 | + if (rows <= 0) return Uint8List(0); | ||
| 104 | + | ||
| 105 | + final bytes = rows * view.width * 4; | ||
| 106 | + final buf = calloc<Uint8>(bytes); | ||
| 107 | + try { | ||
| 108 | + // `bytes` is the real allocation size, not a restatement of the band: V | ||
| 109 | + // checks it before writing, so an arithmetic slip here is caught there | ||
| 110 | + // rather than becoming a heap overflow. | ||
| 111 | + _vfMandelbrot(buf, bytes, view.width, view.height, view.centerX, | ||
| 112 | + view.centerY, view.scale, view.maxIter, y0, y1); | ||
| 113 | + // Copy out of native memory before it is freed. | ||
| 114 | + return Uint8List.fromList(buf.asTypedList(bytes)); | ||
| 115 | + } finally { | ||
| 116 | + calloc.free(buf); | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +Future<Uint8List> renderParallel(FractalView view, int tiles) async { | ||
| 121 | + ensureInitialized(); | ||
| 122 | + final count = tiles.clamp(1, view.height); | ||
| 123 | + final rowsPer = (view.height / count).ceil(); | ||
| 124 | + | ||
| 125 | + final futures = <Future<Uint8List>>[]; | ||
| 126 | + for (var t = 0; t < count; t++) { | ||
| 127 | + final y0 = t * rowsPer; | ||
| 128 | + final y1 = ((t + 1) * rowsPer).clamp(0, view.height); | ||
| 129 | + if (y0 >= y1) break; | ||
| 130 | + futures.add(Isolate.run(() => renderBand(view, y0, y1))); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + final bands = await Future.wait(futures); | ||
| 134 | + final out = Uint8List(view.width * view.height * 4); | ||
| 135 | + var offset = 0; | ||
| 136 | + for (final band in bands) { | ||
| 137 | + out.setRange(offset, offset + band.length, band); | ||
| 138 | + offset += band.length; | ||
| 139 | + } | ||
| 140 | + return out; | ||
| 141 | +} | ||
added
lib/src/backend_web.dart +175 -0 | new file mode 100644 | ||
| @@ -0,0 +1,175 @@ | ||
| 1 | +/// Web backend: the same V code compiled to WebAssembly by emscripten and | |
| 2 | +/// driven over `dart:js_interop`. | |
| 3 | +/// | |
| 4 | +/// Selected by the conditional import in `../vflutter_ffi.dart` when | |
| 5 | +/// `dart.library.js_interop` is available. The native twin is | |
| 6 | +/// `backend_native.dart`; the two files must keep the same top-level | |
| 7 | +/// signatures. | |
| 8 | +/// | |
| 9 | +/// The glue is emitted by tool/build_wasm.sh into example/web/ and loaded by | |
| 10 | +/// a <script> tag in example/web/index.html, which defines the global | |
| 11 | +/// `createVFlutterModule`. | |
| 12 | +library; | |
| 13 | + | |
| 14 | +import 'dart:async'; | |
| 15 | +import 'dart:js_interop'; | |
| 16 | +import 'dart:typed_data'; | |
| 17 | + | |
| 18 | +import 'fractal_view.dart'; | |
| 19 | + | |
| 20 | +const String backendName = 'wasm (emscripten)'; | |
| 21 | + | |
| 22 | +/// The module is single-threaded. Splitting into bands still works and still | |
| 23 | +/// produces the identical frame, but the bands run one after another, so the | |
| 24 | +/// "parallel" timing on web is a sequential one. | |
| 25 | +const bool supportsIsolateParallelism = false; | |
| 26 | + | |
| 27 | +@JS('createVFlutterModule') | |
| 28 | +external JSFunction? get _factory; | |
| 29 | + | |
| 30 | +/// The emscripten module object. Only the pieces this package uses are | |
| 31 | +/// declared; `_`-prefixed members are the C exports. | |
| 32 | +extension type _Module._(JSObject _) implements JSObject { | |
| 33 | + @JS('_malloc') | |
| 34 | + external int malloc(int bytes); | |
| 35 | + | |
| 36 | + @JS('_free') | |
| 37 | + external void free(int ptr); | |
| 38 | + | |
| 39 | + @JS('_vf_init') | |
| 40 | + external void vfInit(); | |
| 41 | + | |
| 42 | + @JS('_vf_add') | |
| 43 | + external int vfAdd(int a, int b); | |
| 44 | + | |
| 45 | + @JS('_vf_greet') | |
| 46 | + external int vfGreet(int namePtr); | |
| 47 | + | |
| 48 | + @JS('_vf_free') | |
| 49 | + external void vfFree(int ptr); | |
| 50 | + | |
| 51 | + @JS('_vf_mandelbrot') | |
| 52 | + external void vfMandelbrot(int buf, int bufLen, int w, int h, double cx, | |
| 53 | + double cy, double scale, int maxIter, int y0, int y1); | |
| 54 | + | |
| 55 | + /// Re-read on every use: emscripten replaces the view when the heap grows. | |
| 56 | + external JSUint8Array get HEAPU8; | |
| 57 | + | |
| 58 | + external String UTF8ToString(int ptr); | |
| 59 | + external void stringToUTF8(String s, int ptr, int maxBytesToWrite); | |
| 60 | + external int lengthBytesUTF8(String s); | |
| 61 | +} | |
| 62 | + | |
| 63 | +extension type _U8Array._(JSObject _) implements JSObject { | |
| 64 | + external JSUint8Array subarray(int begin, int end); | |
| 65 | +} | |
| 66 | + | |
| 67 | +_Module? _module; | |
| 68 | + | |
| 69 | +bool get isInitialized => _module != null; | |
| 70 | + | |
| 71 | +Future<void>? _pending; | |
| 72 | + | |
| 73 | +/// Instantiates the wasm module. Safe to call repeatedly and from several | |
| 74 | +/// places at once; the work happens once. | |
| 75 | +Future<void> initialize() { | |
| 76 | + if (_module != null) return Future<void>.value(); | |
| 77 | + return _pending ??= _load(); | |
| 78 | +} | |
| 79 | + | |
| 80 | +Future<void> _load() async { | |
| 81 | + final factory = _factory; | |
| 82 | + if (factory == null) { | |
| 83 | + throw StateError( | |
| 84 | + 'createVFlutterModule is not defined. The wasm glue is missing: run ' | |
| 85 | + '`just wasm` and make sure web/index.html loads vflutter.js before ' | |
| 86 | + 'flutter_bootstrap.js.', | |
| 87 | + ); | |
| 88 | + } | |
| 89 | + final module = await (factory.callAsFunction() as JSPromise<JSObject>).toDart; | |
| 90 | + final m = _Module._(module); | |
| 91 | + m.vfInit(); | |
| 92 | + _module = m; | |
| 93 | + _pending = null; | |
| 94 | +} | |
| 95 | + | |
| 96 | +_Module get _m { | |
| 97 | + final m = _module; | |
| 98 | + if (m == null) { | |
| 99 | + throw StateError( | |
| 100 | + 'vflutter_ffi is not initialised on web. Await initialize() before ' | |
| 101 | + 'calling into V — loading a wasm module cannot be synchronous.', | |
| 102 | + ); | |
| 103 | + } | |
| 104 | + return m; | |
| 105 | +} | |
| 106 | + | |
| 107 | +/// Present so callers written against the native backend keep compiling. On | |
| 108 | +/// web it only asserts; it cannot do the loading, because that is async. | |
| 109 | +void ensureInitialized() => _m; | |
| 110 | + | |
| 111 | +int add(int a, int b) => _m.vfAdd(a, b); | |
| 112 | + | |
| 113 | +String greet(String name) { | |
| 114 | + final m = _m; | |
| 115 | + // stringToUTF8 needs room for the trailing NUL. | |
| 116 | + final size = m.lengthBytesUTF8(name) + 1; | |
| 117 | + final arg = m.malloc(size); | |
| 118 | + var res = 0; | |
| 119 | + try { | |
| 120 | + m.stringToUTF8(name, arg, size); | |
| 121 | + res = m.vfGreet(arg); | |
| 122 | + if (res == 0) { | |
| 123 | + throw StateError('vf_greet returned null'); | |
| 124 | + } | |
| 125 | + return m.UTF8ToString(res); | |
| 126 | + } finally { | |
| 127 | + m.free(arg); | |
| 128 | + // V allocated the result, so V releases it — same rule as on native. | |
| 129 | + if (res != 0) m.vfFree(res); | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +/// No isolates on web, so this is [greet] behind a `Future`. It does not move | |
| 134 | +/// the work off the UI thread. | |
| 135 | +Future<String> greetAsync(String name) async => greet(name); | |
| 136 | + | |
| 137 | +Uint8List renderBand(FractalView view, int y0, int y1) { | |
| 138 | + final m = _m; | |
| 139 | + final rows = y1 - y0; | |
| 140 | + if (rows <= 0) return Uint8List(0); | |
| 141 | + | |
| 142 | + final bytes = rows * view.width * 4; | |
| 143 | + final buf = m.malloc(bytes); | |
| 144 | + try { | |
| 145 | + m.vfMandelbrot(buf, bytes, view.width, view.height, view.centerX, | |
| 146 | + view.centerY, view.scale, view.maxIter, y0, y1); | |
| 147 | + // Take a view of just this band and copy it into Dart before the wasm | |
| 148 | + // allocation is released. Reading m.HEAPU8 here rather than earlier | |
| 149 | + // matters: malloc may have grown the heap and replaced the view. | |
| 150 | + final band = _U8Array._(m.HEAPU8).subarray(buf, buf + bytes).toDart; | |
| 151 | + return Uint8List.fromList(band); | |
| 152 | + } finally { | |
| 153 | + m.free(buf); | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +/// Splits the frame the same way the native backend does, so the output is | |
| 158 | +/// identical — but the bands are computed one after another. See | |
| 159 | +/// [supportsIsolateParallelism]. | |
| 160 | +Future<Uint8List> renderParallel(FractalView view, int tiles) async { | |
| 161 | + final count = tiles.clamp(1, view.height); | |
| 162 | + final rowsPer = (view.height / count).ceil(); | |
| 163 | + | |
| 164 | + final out = Uint8List(view.width * view.height * 4); | |
| 165 | + var offset = 0; | |
| 166 | + for (var t = 0; t < count; t++) { | |
| 167 | + final y0 = t * rowsPer; | |
| 168 | + final y1 = ((t + 1) * rowsPer).clamp(0, view.height); | |
| 169 | + if (y0 >= y1) break; | |
| 170 | + final band = renderBand(view, y0, y1); | |
| 171 | + out.setRange(offset, offset + band.length, band); | |
| 172 | + offset += band.length; | |
| 173 | + } | |
| 174 | + return out; | |
| 175 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,175 @@ | |||
| 1 | +/// Web backend: the same V code compiled to WebAssembly by emscripten and | ||
| 2 | +/// driven over `dart:js_interop`. | ||
| 3 | +/// | ||
| 4 | +/// Selected by the conditional import in `../vflutter_ffi.dart` when | ||
| 5 | +/// `dart.library.js_interop` is available. The native twin is | ||
| 6 | +/// `backend_native.dart`; the two files must keep the same top-level | ||
| 7 | +/// signatures. | ||
| 8 | +/// | ||
| 9 | +/// The glue is emitted by tool/build_wasm.sh into example/web/ and loaded by | ||
| 10 | +/// a <script> tag in example/web/index.html, which defines the global | ||
| 11 | +/// `createVFlutterModule`. | ||
| 12 | +library; | ||
| 13 | + | ||
| 14 | +import 'dart:async'; | ||
| 15 | +import 'dart:js_interop'; | ||
| 16 | +import 'dart:typed_data'; | ||
| 17 | + | ||
| 18 | +import 'fractal_view.dart'; | ||
| 19 | + | ||
| 20 | +const String backendName = 'wasm (emscripten)'; | ||
| 21 | + | ||
| 22 | +/// The module is single-threaded. Splitting into bands still works and still | ||
| 23 | +/// produces the identical frame, but the bands run one after another, so the | ||
| 24 | +/// "parallel" timing on web is a sequential one. | ||
| 25 | +const bool supportsIsolateParallelism = false; | ||
| 26 | + | ||
| 27 | +@JS('createVFlutterModule') | ||
| 28 | +external JSFunction? get _factory; | ||
| 29 | + | ||
| 30 | +/// The emscripten module object. Only the pieces this package uses are | ||
| 31 | +/// declared; `_`-prefixed members are the C exports. | ||
| 32 | +extension type _Module._(JSObject _) implements JSObject { | ||
| 33 | + @JS('_malloc') | ||
| 34 | + external int malloc(int bytes); | ||
| 35 | + | ||
| 36 | + @JS('_free') | ||
| 37 | + external void free(int ptr); | ||
| 38 | + | ||
| 39 | + @JS('_vf_init') | ||
| 40 | + external void vfInit(); | ||
| 41 | + | ||
| 42 | + @JS('_vf_add') | ||
| 43 | + external int vfAdd(int a, int b); | ||
| 44 | + | ||
| 45 | + @JS('_vf_greet') | ||
| 46 | + external int vfGreet(int namePtr); | ||
| 47 | + | ||
| 48 | + @JS('_vf_free') | ||
| 49 | + external void vfFree(int ptr); | ||
| 50 | + | ||
| 51 | + @JS('_vf_mandelbrot') | ||
| 52 | + external void vfMandelbrot(int buf, int bufLen, int w, int h, double cx, | ||
| 53 | + double cy, double scale, int maxIter, int y0, int y1); | ||
| 54 | + | ||
| 55 | + /// Re-read on every use: emscripten replaces the view when the heap grows. | ||
| 56 | + external JSUint8Array get HEAPU8; | ||
| 57 | + | ||
| 58 | + external String UTF8ToString(int ptr); | ||
| 59 | + external void stringToUTF8(String s, int ptr, int maxBytesToWrite); | ||
| 60 | + external int lengthBytesUTF8(String s); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +extension type _U8Array._(JSObject _) implements JSObject { | ||
| 64 | + external JSUint8Array subarray(int begin, int end); | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +_Module? _module; | ||
| 68 | + | ||
| 69 | +bool get isInitialized => _module != null; | ||
| 70 | + | ||
| 71 | +Future<void>? _pending; | ||
| 72 | + | ||
| 73 | +/// Instantiates the wasm module. Safe to call repeatedly and from several | ||
| 74 | +/// places at once; the work happens once. | ||
| 75 | +Future<void> initialize() { | ||
| 76 | + if (_module != null) return Future<void>.value(); | ||
| 77 | + return _pending ??= _load(); | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +Future<void> _load() async { | ||
| 81 | + final factory = _factory; | ||
| 82 | + if (factory == null) { | ||
| 83 | + throw StateError( | ||
| 84 | + 'createVFlutterModule is not defined. The wasm glue is missing: run ' | ||
| 85 | + '`just wasm` and make sure web/index.html loads vflutter.js before ' | ||
| 86 | + 'flutter_bootstrap.js.', | ||
| 87 | + ); | ||
| 88 | + } | ||
| 89 | + final module = await (factory.callAsFunction() as JSPromise<JSObject>).toDart; | ||
| 90 | + final m = _Module._(module); | ||
| 91 | + m.vfInit(); | ||
| 92 | + _module = m; | ||
| 93 | + _pending = null; | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +_Module get _m { | ||
| 97 | + final m = _module; | ||
| 98 | + if (m == null) { | ||
| 99 | + throw StateError( | ||
| 100 | + 'vflutter_ffi is not initialised on web. Await initialize() before ' | ||
| 101 | + 'calling into V — loading a wasm module cannot be synchronous.', | ||
| 102 | + ); | ||
| 103 | + } | ||
| 104 | + return m; | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/// Present so callers written against the native backend keep compiling. On | ||
| 108 | +/// web it only asserts; it cannot do the loading, because that is async. | ||
| 109 | +void ensureInitialized() => _m; | ||
| 110 | + | ||
| 111 | +int add(int a, int b) => _m.vfAdd(a, b); | ||
| 112 | + | ||
| 113 | +String greet(String name) { | ||
| 114 | + final m = _m; | ||
| 115 | + // stringToUTF8 needs room for the trailing NUL. | ||
| 116 | + final size = m.lengthBytesUTF8(name) + 1; | ||
| 117 | + final arg = m.malloc(size); | ||
| 118 | + var res = 0; | ||
| 119 | + try { | ||
| 120 | + m.stringToUTF8(name, arg, size); | ||
| 121 | + res = m.vfGreet(arg); | ||
| 122 | + if (res == 0) { | ||
| 123 | + throw StateError('vf_greet returned null'); | ||
| 124 | + } | ||
| 125 | + return m.UTF8ToString(res); | ||
| 126 | + } finally { | ||
| 127 | + m.free(arg); | ||
| 128 | + // V allocated the result, so V releases it — same rule as on native. | ||
| 129 | + if (res != 0) m.vfFree(res); | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +/// No isolates on web, so this is [greet] behind a `Future`. It does not move | ||
| 134 | +/// the work off the UI thread. | ||
| 135 | +Future<String> greetAsync(String name) async => greet(name); | ||
| 136 | + | ||
| 137 | +Uint8List renderBand(FractalView view, int y0, int y1) { | ||
| 138 | + final m = _m; | ||
| 139 | + final rows = y1 - y0; | ||
| 140 | + if (rows <= 0) return Uint8List(0); | ||
| 141 | + | ||
| 142 | + final bytes = rows * view.width * 4; | ||
| 143 | + final buf = m.malloc(bytes); | ||
| 144 | + try { | ||
| 145 | + m.vfMandelbrot(buf, bytes, view.width, view.height, view.centerX, | ||
| 146 | + view.centerY, view.scale, view.maxIter, y0, y1); | ||
| 147 | + // Take a view of just this band and copy it into Dart before the wasm | ||
| 148 | + // allocation is released. Reading m.HEAPU8 here rather than earlier | ||
| 149 | + // matters: malloc may have grown the heap and replaced the view. | ||
| 150 | + final band = _U8Array._(m.HEAPU8).subarray(buf, buf + bytes).toDart; | ||
| 151 | + return Uint8List.fromList(band); | ||
| 152 | + } finally { | ||
| 153 | + m.free(buf); | ||
| 154 | + } | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +/// Splits the frame the same way the native backend does, so the output is | ||
| 158 | +/// identical — but the bands are computed one after another. See | ||
| 159 | +/// [supportsIsolateParallelism]. | ||
| 160 | +Future<Uint8List> renderParallel(FractalView view, int tiles) async { | ||
| 161 | + final count = tiles.clamp(1, view.height); | ||
| 162 | + final rowsPer = (view.height / count).ceil(); | ||
| 163 | + | ||
| 164 | + final out = Uint8List(view.width * view.height * 4); | ||
| 165 | + var offset = 0; | ||
| 166 | + for (var t = 0; t < count; t++) { | ||
| 167 | + final y0 = t * rowsPer; | ||
| 168 | + final y1 = ((t + 1) * rowsPer).clamp(0, view.height); | ||
| 169 | + if (y0 >= y1) break; | ||
| 170 | + final band = renderBand(view, y0, y1); | ||
| 171 | + out.setRange(offset, offset + band.length, band); | ||
| 172 | + offset += band.length; | ||
| 173 | + } | ||
| 174 | + return out; | ||
| 175 | +} | ||
added
lib/src/fractal_view.dart +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +/// Where to look in the complex plane — pure Dart, shared by every backend. | |
| 2 | +/// | |
| 3 | +/// Lives apart from the bridge so the web build never drags `dart:ffi` in | |
| 4 | +/// through a geometry type. | |
| 5 | +library; | |
| 6 | + | |
| 7 | +/// Where to look in the complex plane, and how hard to look. | |
| 8 | +class FractalView { | |
| 9 | + const FractalView({ | |
| 10 | + required this.width, | |
| 11 | + required this.height, | |
| 12 | + this.centerX = -0.5, | |
| 13 | + this.centerY = 0.0, | |
| 14 | + this.scale = 3.0, | |
| 15 | + this.maxIter = 500, | |
| 16 | + }); | |
| 17 | + | |
| 18 | + final int width; | |
| 19 | + final int height; | |
| 20 | + | |
| 21 | + /// Centre of the viewport in the complex plane. | |
| 22 | + final double centerX; | |
| 23 | + final double centerY; | |
| 24 | + | |
| 25 | + /// Width of the viewport in the complex plane. Smaller = deeper zoom. | |
| 26 | + final double scale; | |
| 27 | + | |
| 28 | + /// Escape-time iteration cap. The cost of a frame scales with this. | |
| 29 | + final int maxIter; | |
| 30 | + | |
| 31 | + /// Returns this view zoomed by [factor] about a point given in *fractional* | |
| 32 | + /// viewport coordinates — (0,0) top-left, (1,1) bottom-right. | |
| 33 | + /// | |
| 34 | + /// The point under the cursor stays under the cursor: that is the whole | |
| 35 | + /// contract, and it is what makes click-to-zoom feel anchored rather than | |
| 36 | + /// drifting. `factor < 1` zooms in. | |
| 37 | + FractalView zoomedAt(double fx, double fy, double factor) { | |
| 38 | + final aspect = width / height; | |
| 39 | + final ox = fx - 0.5; | |
| 40 | + final oy = fy - 0.5; | |
| 41 | + | |
| 42 | + // The complex-plane point currently under (fx, fy). | |
| 43 | + final targetX = centerX + ox * scale * aspect; | |
| 44 | + final targetY = centerY + oy * scale; | |
| 45 | + | |
| 46 | + final newScale = scale * factor; | |
| 47 | + return copyWith( | |
| 48 | + scale: newScale, | |
| 49 | + centerX: targetX - ox * newScale * aspect, | |
| 50 | + centerY: targetY - oy * newScale, | |
| 51 | + ); | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Returns this view dragged by a delta given as a *fraction* of the | |
| 55 | + /// viewport — dragging right by half the width is `fdx = 0.5`. | |
| 56 | + /// | |
| 57 | + /// The image follows the finger, so the centre moves the opposite way. | |
| 58 | + FractalView pannedBy(double fdx, double fdy) { | |
| 59 | + final aspect = width / height; | |
| 60 | + return copyWith( | |
| 61 | + centerX: centerX - fdx * scale * aspect, | |
| 62 | + centerY: centerY - fdy * scale, | |
| 63 | + ); | |
| 64 | + } | |
| 65 | + | |
| 66 | + FractalView copyWith({ | |
| 67 | + int? width, | |
| 68 | + int? height, | |
| 69 | + double? centerX, | |
| 70 | + double? centerY, | |
| 71 | + double? scale, | |
| 72 | + int? maxIter, | |
| 73 | + }) => | |
| 74 | + FractalView( | |
| 75 | + width: width ?? this.width, | |
| 76 | + height: height ?? this.height, | |
| 77 | + centerX: centerX ?? this.centerX, | |
| 78 | + centerY: centerY ?? this.centerY, | |
| 79 | + scale: scale ?? this.scale, | |
| 80 | + maxIter: maxIter ?? this.maxIter, | |
| 81 | + ); | |
| 82 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +/// Where to look in the complex plane — pure Dart, shared by every backend. | ||
| 2 | +/// | ||
| 3 | +/// Lives apart from the bridge so the web build never drags `dart:ffi` in | ||
| 4 | +/// through a geometry type. | ||
| 5 | +library; | ||
| 6 | + | ||
| 7 | +/// Where to look in the complex plane, and how hard to look. | ||
| 8 | +class FractalView { | ||
| 9 | + const FractalView({ | ||
| 10 | + required this.width, | ||
| 11 | + required this.height, | ||
| 12 | + this.centerX = -0.5, | ||
| 13 | + this.centerY = 0.0, | ||
| 14 | + this.scale = 3.0, | ||
| 15 | + this.maxIter = 500, | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + final int width; | ||
| 19 | + final int height; | ||
| 20 | + | ||
| 21 | + /// Centre of the viewport in the complex plane. | ||
| 22 | + final double centerX; | ||
| 23 | + final double centerY; | ||
| 24 | + | ||
| 25 | + /// Width of the viewport in the complex plane. Smaller = deeper zoom. | ||
| 26 | + final double scale; | ||
| 27 | + | ||
| 28 | + /// Escape-time iteration cap. The cost of a frame scales with this. | ||
| 29 | + final int maxIter; | ||
| 30 | + | ||
| 31 | + /// Returns this view zoomed by [factor] about a point given in *fractional* | ||
| 32 | + /// viewport coordinates — (0,0) top-left, (1,1) bottom-right. | ||
| 33 | + /// | ||
| 34 | + /// The point under the cursor stays under the cursor: that is the whole | ||
| 35 | + /// contract, and it is what makes click-to-zoom feel anchored rather than | ||
| 36 | + /// drifting. `factor < 1` zooms in. | ||
| 37 | + FractalView zoomedAt(double fx, double fy, double factor) { | ||
| 38 | + final aspect = width / height; | ||
| 39 | + final ox = fx - 0.5; | ||
| 40 | + final oy = fy - 0.5; | ||
| 41 | + | ||
| 42 | + // The complex-plane point currently under (fx, fy). | ||
| 43 | + final targetX = centerX + ox * scale * aspect; | ||
| 44 | + final targetY = centerY + oy * scale; | ||
| 45 | + | ||
| 46 | + final newScale = scale * factor; | ||
| 47 | + return copyWith( | ||
| 48 | + scale: newScale, | ||
| 49 | + centerX: targetX - ox * newScale * aspect, | ||
| 50 | + centerY: targetY - oy * newScale, | ||
| 51 | + ); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /// Returns this view dragged by a delta given as a *fraction* of the | ||
| 55 | + /// viewport — dragging right by half the width is `fdx = 0.5`. | ||
| 56 | + /// | ||
| 57 | + /// The image follows the finger, so the centre moves the opposite way. | ||
| 58 | + FractalView pannedBy(double fdx, double fdy) { | ||
| 59 | + final aspect = width / height; | ||
| 60 | + return copyWith( | ||
| 61 | + centerX: centerX - fdx * scale * aspect, | ||
| 62 | + centerY: centerY - fdy * scale, | ||
| 63 | + ); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + FractalView copyWith({ | ||
| 67 | + int? width, | ||
| 68 | + int? height, | ||
| 69 | + double? centerX, | ||
| 70 | + double? centerY, | ||
| 71 | + double? scale, | ||
| 72 | + int? maxIter, | ||
| 73 | + }) => | ||
| 74 | + FractalView( | ||
| 75 | + width: width ?? this.width, | ||
| 76 | + height: height ?? this.height, | ||
| 77 | + centerX: centerX ?? this.centerX, | ||
| 78 | + centerY: centerY ?? this.centerY, | ||
| 79 | + scale: scale ?? this.scale, | ||
| 80 | + maxIter: maxIter ?? this.maxIter, | ||
| 81 | + ); | ||
| 82 | +} | ||
added
tool/bin/emcc +28 -0 | new file mode 100755 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +#!/usr/bin/env dotslash | |
| 2 | + | |
| 3 | +// The emscripten C -> WebAssembly compiler, pinned by content hash. | |
| 4 | +// | |
| 5 | +// Nothing is installed: DotSlash fetches this on first use and caches it under | |
| 6 | +// ~/.cache/dotslash. Version 6.0.9, resolved via emsdk's | |
| 7 | +// emscripten-releases-tags.json to the build hash in the URLs below. | |
| 8 | +// | |
| 9 | +// Regenerate an entry with: | |
| 10 | +// dotslash -- create-url-entry <url> | |
| 11 | + | |
| 12 | +{ | |
| 13 | + "name": "emcc", | |
| 14 | + "platforms": { | |
| 15 | + "linux-x86_64": { | |
| 16 | + "size": 298541944, | |
| 17 | + "hash": "blake3", | |
| 18 | + "digest": "59405b7a7d7c02798b68e616f8e0f9ededcbec4dbe868cebf63a92ff533d252b", | |
| 19 | + "format": "tar.xz", | |
| 20 | + "path": "install/emscripten/emcc", | |
| 21 | + "providers": [ | |
| 22 | + { | |
| 23 | + "url": "https://storage.googleapis.com/webassembly/emscripten-releases-builds/linux/f04ea239d533260dd1db760dd2d668d5f9a88d6b/wasm-binaries.tar.xz" | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100755 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +#!/usr/bin/env dotslash | ||
| 2 | + | ||
| 3 | +// The emscripten C -> WebAssembly compiler, pinned by content hash. | ||
| 4 | +// | ||
| 5 | +// Nothing is installed: DotSlash fetches this on first use and caches it under | ||
| 6 | +// ~/.cache/dotslash. Version 6.0.9, resolved via emsdk's | ||
| 7 | +// emscripten-releases-tags.json to the build hash in the URLs below. | ||
| 8 | +// | ||
| 9 | +// Regenerate an entry with: | ||
| 10 | +// dotslash -- create-url-entry <url> | ||
| 11 | + | ||
| 12 | +{ | ||
| 13 | + "name": "emcc", | ||
| 14 | + "platforms": { | ||
| 15 | + "linux-x86_64": { | ||
| 16 | + "size": 298541944, | ||
| 17 | + "hash": "blake3", | ||
| 18 | + "digest": "59405b7a7d7c02798b68e616f8e0f9ededcbec4dbe868cebf63a92ff533d252b", | ||
| 19 | + "format": "tar.xz", | ||
| 20 | + "path": "install/emscripten/emcc", | ||
| 21 | + "providers": [ | ||
| 22 | + { | ||
| 23 | + "url": "https://storage.googleapis.com/webassembly/emscripten-releases-builds/linux/f04ea239d533260dd1db760dd2d668d5f9a88d6b/wasm-binaries.tar.xz" | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
tool/build_wasm.sh +68 -0 | new file mode 100755 | ||
| @@ -0,0 +1,68 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# V -> WebAssembly, for the Flutter web build of the example. | |
| 3 | +# | |
| 4 | +# Nothing is installed system-wide. tool/bin/emcc is a DotSlash file pinning | |
| 5 | +# emscripten by content hash; DotSlash fetches it on first run (~285 MB) and | |
| 6 | +# caches it under ~/.cache/dotslash. Only `dotslash` itself has to be on PATH. | |
| 7 | +# | |
| 8 | +# Output lands in example/web/ and is loaded by a <script> tag in | |
| 9 | +# example/web/index.html. Re-run after editing src/vflutter.v. | |
| 10 | +set -euo pipefail | |
| 11 | +cd "$(dirname "$0")/.." | |
| 12 | + | |
| 13 | +command -v v >/dev/null || { echo "V compiler not on PATH"; exit 1; } | |
| 14 | +command -v node >/dev/null || { echo "node not on PATH (emscripten needs it)"; exit 1; } | |
| 15 | +command -v dotslash >/dev/null || { | |
| 16 | + echo "dotslash not on PATH — https://dotslash-cli.com/docs/installation/" >&2 | |
| 17 | + exit 1 | |
| 18 | +} | |
| 19 | + | |
| 20 | +out=example/web | |
| 21 | +mkdir -p build "$out" | |
| 22 | + | |
| 23 | +# Fetches on first call, then just prints the cached path. | |
| 24 | +emcc_path="$(dotslash -- fetch tool/bin/emcc)" | |
| 25 | +root="$(dirname "$(dirname "$emcc_path")")" | |
| 26 | + | |
| 27 | +# emcc wants a config file; the DotSlash cache is content-addressed and must | |
| 28 | +# stay read-only, so the config and the sysroot cache live under build/. | |
| 29 | +cat > build/emscripten.config <<EOF | |
| 30 | +LLVM_ROOT = '$root/bin' | |
| 31 | +BINARYEN_ROOT = '$root' | |
| 32 | +NODE_JS = '$(command -v node)' | |
| 33 | +CACHE = '$PWD/build/emcache' | |
| 34 | +EOF | |
| 35 | +export EM_CONFIG="$PWD/build/emscripten.config" | |
| 36 | + | |
| 37 | +# V emits C for the emscripten target; emcc turns that into wasm. -gc none | |
| 38 | +# matches every other build, so the ownership rules are the same ones the | |
| 39 | +# native library exercises. | |
| 40 | +v -os wasm32_emscripten -gc none -o build/vflutter_em.c src/vflutter.v | |
| 41 | + | |
| 42 | +# GLOBAL_BASE is not cosmetic. V's builtin vmemcpy refuses any copy whose | |
| 43 | +# source or destination is <= 0xFFFF: | |
| 44 | +# | |
| 45 | +# if (n == 0 || (u64)dest <= 0xFFFF || (u64)const_src <= 0xFFFF) return dest; | |
| 46 | +# | |
| 47 | +# That is a null-pointer heuristic written for 64-bit desktop, where the low | |
| 48 | +# 64 KB is never mapped. On wasm32 emscripten packs static data down at | |
| 49 | +# address ~1300 by default, so every copy out of a string literal silently | |
| 50 | +# does nothing and V strings come back as runs of zero bytes -- no crash, no | |
| 51 | +# diagnostic. (It only bites at -O1 and above; at -O0 the data happens to land | |
| 52 | +# above the threshold, which makes this look like an optimiser bug.) Starting | |
| 53 | +# static data at 1 MB puts it, and the stack and heap that follow it, clear of | |
| 54 | +# the guard. Verified by tool/test_wasm.mjs, which round-trips a string. | |
| 55 | +# | |
| 56 | +# ENVIRONMENT includes node so the module can be smoke-tested headlessly | |
| 57 | +# (tool/test_wasm.mjs) without a browser. | |
| 58 | +dotslash tool/bin/emcc build/vflutter_em.c -O3 -o "$out/vflutter.js" \ | |
| 59 | + -sGLOBAL_BASE=1048576 \ | |
| 60 | + -sMODULARIZE=1 \ | |
| 61 | + -sEXPORT_NAME=createVFlutterModule \ | |
| 62 | + -sENVIRONMENT=web,worker,node \ | |
| 63 | + -sALLOW_MEMORY_GROWTH=1 \ | |
| 64 | + -sEXPORTED_FUNCTIONS='["_vf_init","_vf_add","_vf_greet","_vf_free","_vf_mandelbrot","_malloc","_free"]' \ | |
| 65 | + -sEXPORTED_RUNTIME_METHODS='["ccall","cwrap","UTF8ToString","stringToUTF8","lengthBytesUTF8","HEAPU8"]' | |
| 66 | + | |
| 67 | +echo "built: $out/vflutter.js ($(stat -c%s "$out/vflutter.js") bytes)," \ | |
| 68 | + "$out/vflutter.wasm ($(stat -c%s "$out/vflutter.wasm") bytes)" | |
| new file mode 100755 | |||
| @@ -0,0 +1,68 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# V -> WebAssembly, for the Flutter web build of the example. | ||
| 3 | +# | ||
| 4 | +# Nothing is installed system-wide. tool/bin/emcc is a DotSlash file pinning | ||
| 5 | +# emscripten by content hash; DotSlash fetches it on first run (~285 MB) and | ||
| 6 | +# caches it under ~/.cache/dotslash. Only `dotslash` itself has to be on PATH. | ||
| 7 | +# | ||
| 8 | +# Output lands in example/web/ and is loaded by a <script> tag in | ||
| 9 | +# example/web/index.html. Re-run after editing src/vflutter.v. | ||
| 10 | +set -euo pipefail | ||
| 11 | +cd "$(dirname "$0")/.." | ||
| 12 | + | ||
| 13 | +command -v v >/dev/null || { echo "V compiler not on PATH"; exit 1; } | ||
| 14 | +command -v node >/dev/null || { echo "node not on PATH (emscripten needs it)"; exit 1; } | ||
| 15 | +command -v dotslash >/dev/null || { | ||
| 16 | + echo "dotslash not on PATH — https://dotslash-cli.com/docs/installation/" >&2 | ||
| 17 | + exit 1 | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +out=example/web | ||
| 21 | +mkdir -p build "$out" | ||
| 22 | + | ||
| 23 | +# Fetches on first call, then just prints the cached path. | ||
| 24 | +emcc_path="$(dotslash -- fetch tool/bin/emcc)" | ||
| 25 | +root="$(dirname "$(dirname "$emcc_path")")" | ||
| 26 | + | ||
| 27 | +# emcc wants a config file; the DotSlash cache is content-addressed and must | ||
| 28 | +# stay read-only, so the config and the sysroot cache live under build/. | ||
| 29 | +cat > build/emscripten.config <<EOF | ||
| 30 | +LLVM_ROOT = '$root/bin' | ||
| 31 | +BINARYEN_ROOT = '$root' | ||
| 32 | +NODE_JS = '$(command -v node)' | ||
| 33 | +CACHE = '$PWD/build/emcache' | ||
| 34 | +EOF | ||
| 35 | +export EM_CONFIG="$PWD/build/emscripten.config" | ||
| 36 | + | ||
| 37 | +# V emits C for the emscripten target; emcc turns that into wasm. -gc none | ||
| 38 | +# matches every other build, so the ownership rules are the same ones the | ||
| 39 | +# native library exercises. | ||
| 40 | +v -os wasm32_emscripten -gc none -o build/vflutter_em.c src/vflutter.v | ||
| 41 | + | ||
| 42 | +# GLOBAL_BASE is not cosmetic. V's builtin vmemcpy refuses any copy whose | ||
| 43 | +# source or destination is <= 0xFFFF: | ||
| 44 | +# | ||
| 45 | +# if (n == 0 || (u64)dest <= 0xFFFF || (u64)const_src <= 0xFFFF) return dest; | ||
| 46 | +# | ||
| 47 | +# That is a null-pointer heuristic written for 64-bit desktop, where the low | ||
| 48 | +# 64 KB is never mapped. On wasm32 emscripten packs static data down at | ||
| 49 | +# address ~1300 by default, so every copy out of a string literal silently | ||
| 50 | +# does nothing and V strings come back as runs of zero bytes -- no crash, no | ||
| 51 | +# diagnostic. (It only bites at -O1 and above; at -O0 the data happens to land | ||
| 52 | +# above the threshold, which makes this look like an optimiser bug.) Starting | ||
| 53 | +# static data at 1 MB puts it, and the stack and heap that follow it, clear of | ||
| 54 | +# the guard. Verified by tool/test_wasm.mjs, which round-trips a string. | ||
| 55 | +# | ||
| 56 | +# ENVIRONMENT includes node so the module can be smoke-tested headlessly | ||
| 57 | +# (tool/test_wasm.mjs) without a browser. | ||
| 58 | +dotslash tool/bin/emcc build/vflutter_em.c -O3 -o "$out/vflutter.js" \ | ||
| 59 | + -sGLOBAL_BASE=1048576 \ | ||
| 60 | + -sMODULARIZE=1 \ | ||
| 61 | + -sEXPORT_NAME=createVFlutterModule \ | ||
| 62 | + -sENVIRONMENT=web,worker,node \ | ||
| 63 | + -sALLOW_MEMORY_GROWTH=1 \ | ||
| 64 | + -sEXPORTED_FUNCTIONS='["_vf_init","_vf_add","_vf_greet","_vf_free","_vf_mandelbrot","_malloc","_free"]' \ | ||
| 65 | + -sEXPORTED_RUNTIME_METHODS='["ccall","cwrap","UTF8ToString","stringToUTF8","lengthBytesUTF8","HEAPU8"]' | ||
| 66 | + | ||
| 67 | +echo "built: $out/vflutter.js ($(stat -c%s "$out/vflutter.js") bytes)," \ | ||
| 68 | + "$out/vflutter.wasm ($(stat -c%s "$out/vflutter.wasm") bytes)" | ||
added
tool/check_parity.sh +55 -0 | new file mode 100755 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# The same Mandelbrot frame from the native kernel and the wasm kernel, | |
| 3 | +# compared byte for byte. | |
| 4 | +# | |
| 5 | +# The point is not that floating point is deterministic in the abstract — it | |
| 6 | +# is that these two builds go through different compilers, different libm | |
| 7 | +# implementations (glibc vs emscripten's musl) and different word sizes | |
| 8 | +# (64-bit vs wasm32), and still have to agree, or "the same V code everywhere" | |
| 9 | +# is a slogan rather than a fact. | |
| 10 | +set -euo pipefail | |
| 11 | +cd "$(dirname "$0")/.." | |
| 12 | + | |
| 13 | +[ -f build/libvflutter.so ] || { echo "run ./tool/build.sh first"; exit 1; } | |
| 14 | +[ -f example/web/vflutter.js ] || { echo "run ./tool/build_wasm.sh first"; exit 1; } | |
| 15 | + | |
| 16 | +work="$(mktemp -d)" | |
| 17 | +trap 'rm -rf "$work"' EXIT | |
| 18 | + | |
| 19 | +cat > "$work/native_frame.c" <<'EOF' | |
| 20 | +#include "vflutter.h" | |
| 21 | +#include <stdio.h> | |
| 22 | +#include <stdlib.h> | |
| 23 | +int main(int argc, char **argv) { | |
| 24 | + (void)argc; | |
| 25 | + const int W = 240, H = 180, ITER = 300; | |
| 26 | + size_t n = (size_t)W * H * 4; | |
| 27 | + unsigned char *b = calloc(n, 1); | |
| 28 | + vf_init(); | |
| 29 | + vf_mandelbrot(b, n, W, H, -0.5, 0.0, 3.0, ITER, 0, H); | |
| 30 | + FILE *f = fopen(argv[1], "wb"); | |
| 31 | + fwrite(b, 1, n, f); | |
| 32 | + fclose(f); | |
| 33 | + return 0; | |
| 34 | +} | |
| 35 | +EOF | |
| 36 | + | |
| 37 | +cc -O2 -I src -o "$work/native_frame" "$work/native_frame.c" -L build -lvflutter | |
| 38 | +LD_LIBRARY_PATH=build "$work/native_frame" "$work/native.bin" | |
| 39 | +node tool/test_wasm.mjs "$work/wasm.bin" > /dev/null | |
| 40 | + | |
| 41 | +python3 - "$work/native.bin" "$work/wasm.bin" <<'EOF' | |
| 42 | +import sys | |
| 43 | +a = open(sys.argv[1], 'rb').read() | |
| 44 | +b = open(sys.argv[2], 'rb').read() | |
| 45 | +if len(a) != len(b): | |
| 46 | + sys.exit(f"FAIL size {len(a)} != {len(b)}") | |
| 47 | +bad = [(i, x, y) for i, (x, y) in enumerate(zip(a, b)) if x != y] | |
| 48 | +if bad: | |
| 49 | + worst = max(abs(x - y) for _, x, y in bad) | |
| 50 | + print(f"FAIL {len(bad)} of {len(a)} bytes differ (max delta {worst})") | |
| 51 | + for i, x, y in bad[:5]: | |
| 52 | + print(f" byte {i} (px {i//4}, ch {i%4}): native={x} wasm={y}") | |
| 53 | + sys.exit(1) | |
| 54 | +print(f"ok native and wasm kernels agree on all {len(a)} bytes") | |
| 55 | +EOF | |
| new file mode 100755 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# The same Mandelbrot frame from the native kernel and the wasm kernel, | ||
| 3 | +# compared byte for byte. | ||
| 4 | +# | ||
| 5 | +# The point is not that floating point is deterministic in the abstract — it | ||
| 6 | +# is that these two builds go through different compilers, different libm | ||
| 7 | +# implementations (glibc vs emscripten's musl) and different word sizes | ||
| 8 | +# (64-bit vs wasm32), and still have to agree, or "the same V code everywhere" | ||
| 9 | +# is a slogan rather than a fact. | ||
| 10 | +set -euo pipefail | ||
| 11 | +cd "$(dirname "$0")/.." | ||
| 12 | + | ||
| 13 | +[ -f build/libvflutter.so ] || { echo "run ./tool/build.sh first"; exit 1; } | ||
| 14 | +[ -f example/web/vflutter.js ] || { echo "run ./tool/build_wasm.sh first"; exit 1; } | ||
| 15 | + | ||
| 16 | +work="$(mktemp -d)" | ||
| 17 | +trap 'rm -rf "$work"' EXIT | ||
| 18 | + | ||
| 19 | +cat > "$work/native_frame.c" <<'EOF' | ||
| 20 | +#include "vflutter.h" | ||
| 21 | +#include <stdio.h> | ||
| 22 | +#include <stdlib.h> | ||
| 23 | +int main(int argc, char **argv) { | ||
| 24 | + (void)argc; | ||
| 25 | + const int W = 240, H = 180, ITER = 300; | ||
| 26 | + size_t n = (size_t)W * H * 4; | ||
| 27 | + unsigned char *b = calloc(n, 1); | ||
| 28 | + vf_init(); | ||
| 29 | + vf_mandelbrot(b, n, W, H, -0.5, 0.0, 3.0, ITER, 0, H); | ||
| 30 | + FILE *f = fopen(argv[1], "wb"); | ||
| 31 | + fwrite(b, 1, n, f); | ||
| 32 | + fclose(f); | ||
| 33 | + return 0; | ||
| 34 | +} | ||
| 35 | +EOF | ||
| 36 | + | ||
| 37 | +cc -O2 -I src -o "$work/native_frame" "$work/native_frame.c" -L build -lvflutter | ||
| 38 | +LD_LIBRARY_PATH=build "$work/native_frame" "$work/native.bin" | ||
| 39 | +node tool/test_wasm.mjs "$work/wasm.bin" > /dev/null | ||
| 40 | + | ||
| 41 | +python3 - "$work/native.bin" "$work/wasm.bin" <<'EOF' | ||
| 42 | +import sys | ||
| 43 | +a = open(sys.argv[1], 'rb').read() | ||
| 44 | +b = open(sys.argv[2], 'rb').read() | ||
| 45 | +if len(a) != len(b): | ||
| 46 | + sys.exit(f"FAIL size {len(a)} != {len(b)}") | ||
| 47 | +bad = [(i, x, y) for i, (x, y) in enumerate(zip(a, b)) if x != y] | ||
| 48 | +if bad: | ||
| 49 | + worst = max(abs(x - y) for _, x, y in bad) | ||
| 50 | + print(f"FAIL {len(bad)} of {len(a)} bytes differ (max delta {worst})") | ||
| 51 | + for i, x, y in bad[:5]: | ||
| 52 | + print(f" byte {i} (px {i//4}, ch {i%4}): native={x} wasm={y}") | ||
| 53 | + sys.exit(1) | ||
| 54 | +print(f"ok native and wasm kernels agree on all {len(a)} bytes") | ||
| 55 | +EOF | ||
added
tool/check_web.sh +59 -0 | new file mode 100755 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# End-to-end check of the web build: serve it, load it in headless Chrome, | |
| 3 | +# and confirm V actually produced a fractal in the browser. | |
| 4 | +# | |
| 5 | +# "It compiled" is not the bar here. dart:js_interop failures and wasm load | |
| 6 | +# failures both look like a clean build and a blank canvas, so this asserts on | |
| 7 | +# the rendered pixels and on the absence of uncaught exceptions. | |
| 8 | +set -euo pipefail | |
| 9 | +cd "$(dirname "$0")/.." | |
| 10 | + | |
| 11 | +chrome="${CHROME_EXECUTABLE:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || true)}" | |
| 12 | +[ -n "$chrome" ] || { echo "no Chrome found; set CHROME_EXECUTABLE"; exit 1; } | |
| 13 | +[ -d example/build/web ] || { echo "run 'just build-web' first"; exit 1; } | |
| 14 | +[ -f example/build/web/vflutter.wasm ] || { echo "example/build/web/vflutter.wasm missing"; exit 1; } | |
| 15 | + | |
| 16 | +work="$(mktemp -d)" | |
| 17 | +port="${PORT:-8791}" | |
| 18 | +python3 -m http.server "$port" --bind 127.0.0.1 --directory example/build/web >/dev/null 2>&1 & | |
| 19 | +server=$! | |
| 20 | +trap 'kill $server 2>/dev/null || true; rm -rf "$work"' EXIT | |
| 21 | +sleep 2 | |
| 22 | + | |
| 23 | +"$chrome" --headless=new --no-sandbox --disable-dev-shm-usage \ | |
| 24 | + --enable-unsafe-swiftshader --virtual-time-budget=30000 \ | |
| 25 | + --window-size=1280,900 --screenshot="$work/shot.png" \ | |
| 26 | + --enable-logging=stderr --v=1 \ | |
| 27 | + "http://127.0.0.1:$port/index.html" >/dev/null 2>"$work/chrome.log" | |
| 28 | + | |
| 29 | +# Uncaught Dart/JS exceptions surface as CONSOLE lines. | |
| 30 | +if grep -qE 'CONSOLE.*(Uncaught|Unhandled)' "$work/chrome.log"; then | |
| 31 | + echo "FAIL uncaught exception in the browser:" | |
| 32 | + grep -oE 'CONSOLE:[0-9]+\] "[^"]*' "$work/chrome.log" | head -5 | |
| 33 | + exit 1 | |
| 34 | +fi | |
| 35 | + | |
| 36 | +python3 - "$work/shot.png" <<'EOF' | |
| 37 | +import sys | |
| 38 | +from PIL import Image | |
| 39 | + | |
| 40 | +im = Image.open(sys.argv[1]).convert('RGB') | |
| 41 | +w, h = im.size | |
| 42 | +# The canvas is the left ~70%; the right is the control panel. | |
| 43 | +canvas = im.crop((0, 0, int(w * 0.7), h)) | |
| 44 | +colors = canvas.getcolors(maxcolors=1 << 24) or [] | |
| 45 | +distinct = len(colors) | |
| 46 | +black = sum(n for n, c in colors if c == (0, 0, 0)) | |
| 47 | +total = canvas.size[0] * canvas.size[1] | |
| 48 | + | |
| 49 | +print(f"canvas {canvas.size[0]}x{canvas.size[1]}: {distinct} distinct colours, " | |
| 50 | + f"{100 * black / total:.1f}% pure black") | |
| 51 | + | |
| 52 | +# A blank/failed canvas is one flat colour. A real frame has the black | |
| 53 | +# interior of the set plus a smooth coloured exterior. | |
| 54 | +if distinct < 200: | |
| 55 | + sys.exit(f"FAIL canvas has only {distinct} distinct colours — nothing rendered") | |
| 56 | +if not (0.05 < black / total < 0.75): | |
| 57 | + sys.exit(f"FAIL black fraction {black / total:.2f} does not look like the set") | |
| 58 | +print("ok V rendered a Mandelbrot frame in the browser") | |
| 59 | +EOF | |
| new file mode 100755 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# End-to-end check of the web build: serve it, load it in headless Chrome, | ||
| 3 | +# and confirm V actually produced a fractal in the browser. | ||
| 4 | +# | ||
| 5 | +# "It compiled" is not the bar here. dart:js_interop failures and wasm load | ||
| 6 | +# failures both look like a clean build and a blank canvas, so this asserts on | ||
| 7 | +# the rendered pixels and on the absence of uncaught exceptions. | ||
| 8 | +set -euo pipefail | ||
| 9 | +cd "$(dirname "$0")/.." | ||
| 10 | + | ||
| 11 | +chrome="${CHROME_EXECUTABLE:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || true)}" | ||
| 12 | +[ -n "$chrome" ] || { echo "no Chrome found; set CHROME_EXECUTABLE"; exit 1; } | ||
| 13 | +[ -d example/build/web ] || { echo "run 'just build-web' first"; exit 1; } | ||
| 14 | +[ -f example/build/web/vflutter.wasm ] || { echo "example/build/web/vflutter.wasm missing"; exit 1; } | ||
| 15 | + | ||
| 16 | +work="$(mktemp -d)" | ||
| 17 | +port="${PORT:-8791}" | ||
| 18 | +python3 -m http.server "$port" --bind 127.0.0.1 --directory example/build/web >/dev/null 2>&1 & | ||
| 19 | +server=$! | ||
| 20 | +trap 'kill $server 2>/dev/null || true; rm -rf "$work"' EXIT | ||
| 21 | +sleep 2 | ||
| 22 | + | ||
| 23 | +"$chrome" --headless=new --no-sandbox --disable-dev-shm-usage \ | ||
| 24 | + --enable-unsafe-swiftshader --virtual-time-budget=30000 \ | ||
| 25 | + --window-size=1280,900 --screenshot="$work/shot.png" \ | ||
| 26 | + --enable-logging=stderr --v=1 \ | ||
| 27 | + "http://127.0.0.1:$port/index.html" >/dev/null 2>"$work/chrome.log" | ||
| 28 | + | ||
| 29 | +# Uncaught Dart/JS exceptions surface as CONSOLE lines. | ||
| 30 | +if grep -qE 'CONSOLE.*(Uncaught|Unhandled)' "$work/chrome.log"; then | ||
| 31 | + echo "FAIL uncaught exception in the browser:" | ||
| 32 | + grep -oE 'CONSOLE:[0-9]+\] "[^"]*' "$work/chrome.log" | head -5 | ||
| 33 | + exit 1 | ||
| 34 | +fi | ||
| 35 | + | ||
| 36 | +python3 - "$work/shot.png" <<'EOF' | ||
| 37 | +import sys | ||
| 38 | +from PIL import Image | ||
| 39 | + | ||
| 40 | +im = Image.open(sys.argv[1]).convert('RGB') | ||
| 41 | +w, h = im.size | ||
| 42 | +# The canvas is the left ~70%; the right is the control panel. | ||
| 43 | +canvas = im.crop((0, 0, int(w * 0.7), h)) | ||
| 44 | +colors = canvas.getcolors(maxcolors=1 << 24) or [] | ||
| 45 | +distinct = len(colors) | ||
| 46 | +black = sum(n for n, c in colors if c == (0, 0, 0)) | ||
| 47 | +total = canvas.size[0] * canvas.size[1] | ||
| 48 | + | ||
| 49 | +print(f"canvas {canvas.size[0]}x{canvas.size[1]}: {distinct} distinct colours, " | ||
| 50 | + f"{100 * black / total:.1f}% pure black") | ||
| 51 | + | ||
| 52 | +# A blank/failed canvas is one flat colour. A real frame has the black | ||
| 53 | +# interior of the set plus a smooth coloured exterior. | ||
| 54 | +if distinct < 200: | ||
| 55 | + sys.exit(f"FAIL canvas has only {distinct} distinct colours — nothing rendered") | ||
| 56 | +if not (0.05 < black / total < 0.75): | ||
| 57 | + sys.exit(f"FAIL black fraction {black / total:.2f} does not look like the set") | ||
| 58 | +print("ok V rendered a Mandelbrot frame in the browser") | ||
| 59 | +EOF | ||
added
tool/test_wasm.mjs +67 -0 | new file mode 100644 | ||
| @@ -0,0 +1,67 @@ | ||
| 1 | +// Headless check of the wasm build: same contract as the native library. | |
| 2 | +// node tool/test_wasm.mjs [out.bin] | |
| 3 | +// With an argument, writes the raw RGBA frame there so it can be diffed | |
| 4 | +// against the native kernel (tool/test_wasm_parity.sh). | |
| 5 | +import { createRequire } from 'node:module'; | |
| 6 | +import { writeFileSync } from 'node:fs'; | |
| 7 | + | |
| 8 | +const require = createRequire(import.meta.url); | |
| 9 | +const createVFlutterModule = require('../example/web/vflutter.js'); | |
| 10 | +const M = await createVFlutterModule(); | |
| 11 | + | |
| 12 | +let failures = 0; | |
| 13 | +const check = (name, ok, detail = '') => { | |
| 14 | + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); | |
| 15 | + if (!ok) failures++; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +M.ccall('vf_init', null, [], []); | |
| 19 | +check('vf_add', M.ccall('vf_add', 'number', ['number', 'number'], [20, 22]) === 42); | |
| 20 | + | |
| 21 | +// vf_greet returns a V-allocated string the caller must release. | |
| 22 | +const namePtr = M._malloc(16); | |
| 23 | +M.stringToUTF8('wasm', namePtr, 16); | |
| 24 | +const resPtr = M.ccall('vf_greet', 'number', ['number'], [namePtr]); | |
| 25 | +const greeting = M.UTF8ToString(resPtr); | |
| 26 | +M.ccall('vf_free', null, ['number'], [resPtr]); | |
| 27 | +M._free(namePtr); | |
| 28 | +check('vf_greet', greeting === 'Hello, wasm, from V!', greeting); | |
| 29 | + | |
| 30 | +const W = 240, H = 180, ITER = 300, BYTES = W * H * 4; | |
| 31 | +const buf = M._malloc(BYTES); | |
| 32 | + | |
| 33 | +const fill = (len, y0, y1) => { | |
| 34 | + M.HEAPU8.fill(0, buf, buf + BYTES); | |
| 35 | + M.ccall( | |
| 36 | + 'vf_mandelbrot', | |
| 37 | + null, | |
| 38 | + ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number'], | |
| 39 | + [buf, len, W, H, -0.5, 0.0, 3.0, ITER, y0, y1], | |
| 40 | + ); | |
| 41 | + return M.HEAPU8.slice(buf, buf + BYTES); | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const frame = fill(BYTES, 0, H); | |
| 45 | +check('every pixel has alpha', frame.every((v, i) => i % 4 !== 3 || v === 255)); | |
| 46 | +check('frame is a fractal, not a flat fill', new Set(frame).size > 8); | |
| 47 | + | |
| 48 | +// size_t is 32-bit on wasm32 — the guard has to hold there too. | |
| 49 | +check('undersized buf_len writes nothing', fill(BYTES - 1, 0, H).every((v) => v === 0)); | |
| 50 | +check('zero buf_len writes nothing', fill(0, 0, H).every((v) => v === 0)); | |
| 51 | +check('inverted band writes nothing', fill(BYTES, 10, 2).every((v) => v === 0)); | |
| 52 | + | |
| 53 | +// Band split must reassemble into the identical frame. | |
| 54 | +const top = fill(BYTES, 0, H / 2).slice(0, (BYTES / 2)); | |
| 55 | +const bottom = fill(BYTES, H / 2, H).slice(0, (BYTES / 2)); | |
| 56 | +const joined = new Uint8Array(BYTES); | |
| 57 | +joined.set(top, 0); | |
| 58 | +joined.set(bottom, BYTES / 2); | |
| 59 | +check('band split matches a single-shot render', joined.every((v, i) => v === frame[i])); | |
| 60 | + | |
| 61 | +M._free(buf); | |
| 62 | + | |
| 63 | +if (process.argv[2]) { | |
| 64 | + writeFileSync(process.argv[2], Buffer.from(frame)); | |
| 65 | + console.log(`wrote ${process.argv[2]} (${frame.length} bytes)`); | |
| 66 | +} | |
| 67 | +process.exit(failures === 0 ? 0 : 1); | |
| new file mode 100644 | |||
| @@ -0,0 +1,67 @@ | |||
| 1 | +// Headless check of the wasm build: same contract as the native library. | ||
| 2 | +// node tool/test_wasm.mjs [out.bin] | ||
| 3 | +// With an argument, writes the raw RGBA frame there so it can be diffed | ||
| 4 | +// against the native kernel (tool/test_wasm_parity.sh). | ||
| 5 | +import { createRequire } from 'node:module'; | ||
| 6 | +import { writeFileSync } from 'node:fs'; | ||
| 7 | + | ||
| 8 | +const require = createRequire(import.meta.url); | ||
| 9 | +const createVFlutterModule = require('../example/web/vflutter.js'); | ||
| 10 | +const M = await createVFlutterModule(); | ||
| 11 | + | ||
| 12 | +let failures = 0; | ||
| 13 | +const check = (name, ok, detail = '') => { | ||
| 14 | + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); | ||
| 15 | + if (!ok) failures++; | ||
| 16 | +}; | ||
| 17 | + | ||
| 18 | +M.ccall('vf_init', null, [], []); | ||
| 19 | +check('vf_add', M.ccall('vf_add', 'number', ['number', 'number'], [20, 22]) === 42); | ||
| 20 | + | ||
| 21 | +// vf_greet returns a V-allocated string the caller must release. | ||
| 22 | +const namePtr = M._malloc(16); | ||
| 23 | +M.stringToUTF8('wasm', namePtr, 16); | ||
| 24 | +const resPtr = M.ccall('vf_greet', 'number', ['number'], [namePtr]); | ||
| 25 | +const greeting = M.UTF8ToString(resPtr); | ||
| 26 | +M.ccall('vf_free', null, ['number'], [resPtr]); | ||
| 27 | +M._free(namePtr); | ||
| 28 | +check('vf_greet', greeting === 'Hello, wasm, from V!', greeting); | ||
| 29 | + | ||
| 30 | +const W = 240, H = 180, ITER = 300, BYTES = W * H * 4; | ||
| 31 | +const buf = M._malloc(BYTES); | ||
| 32 | + | ||
| 33 | +const fill = (len, y0, y1) => { | ||
| 34 | + M.HEAPU8.fill(0, buf, buf + BYTES); | ||
| 35 | + M.ccall( | ||
| 36 | + 'vf_mandelbrot', | ||
| 37 | + null, | ||
| 38 | + ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number'], | ||
| 39 | + [buf, len, W, H, -0.5, 0.0, 3.0, ITER, y0, y1], | ||
| 40 | + ); | ||
| 41 | + return M.HEAPU8.slice(buf, buf + BYTES); | ||
| 42 | +}; | ||
| 43 | + | ||
| 44 | +const frame = fill(BYTES, 0, H); | ||
| 45 | +check('every pixel has alpha', frame.every((v, i) => i % 4 !== 3 || v === 255)); | ||
| 46 | +check('frame is a fractal, not a flat fill', new Set(frame).size > 8); | ||
| 47 | + | ||
| 48 | +// size_t is 32-bit on wasm32 — the guard has to hold there too. | ||
| 49 | +check('undersized buf_len writes nothing', fill(BYTES - 1, 0, H).every((v) => v === 0)); | ||
| 50 | +check('zero buf_len writes nothing', fill(0, 0, H).every((v) => v === 0)); | ||
| 51 | +check('inverted band writes nothing', fill(BYTES, 10, 2).every((v) => v === 0)); | ||
| 52 | + | ||
| 53 | +// Band split must reassemble into the identical frame. | ||
| 54 | +const top = fill(BYTES, 0, H / 2).slice(0, (BYTES / 2)); | ||
| 55 | +const bottom = fill(BYTES, H / 2, H).slice(0, (BYTES / 2)); | ||
| 56 | +const joined = new Uint8Array(BYTES); | ||
| 57 | +joined.set(top, 0); | ||
| 58 | +joined.set(bottom, BYTES / 2); | ||
| 59 | +check('band split matches a single-shot render', joined.every((v, i) => v === frame[i])); | ||
| 60 | + | ||
| 61 | +M._free(buf); | ||
| 62 | + | ||
| 63 | +if (process.argv[2]) { | ||
| 64 | + writeFileSync(process.argv[2], Buffer.from(frame)); | ||
| 65 | + console.log(`wrote ${process.argv[2]} (${frame.length} bytes)`); | ||
| 66 | +} | ||
| 67 | +process.exit(failures === 0 ? 0 : 1); | ||