nandi/vflutter_ffipublic Fork 0
728acaa
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandithebull committed 2026-09-18T18:03:08-07:00 Browse files
728acaa parent: 0ed90f3
modified .gitignore +6 -0
@@ -15,3 +15,9 @@ build/
1515 # NOTE: ios/Classes/*.gen.c and macos/Classes/*.gen.c are intentionally
1616 # NOT ignored. Xcode cannot run `v`, so that C is checked in on purpose.
1717 # 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 intentionally15 # 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):
6666
6767 ```
6868 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)
6970 just test # analyze + the example's test suite
7071 just bench # V vs Dart timings for the same frame
7172 just --list # everything else
@@ -79,6 +80,7 @@ elsewhere.
7980 ```dart
8081 import 'package:vflutter_ffi/vflutter_ffi.dart' as v;
8182
83+await v.initialize(); // required on web, a formality on native
8284 v.add(20, 22); // 42
8385 v.greet('Flutter'); // "Hello, Flutter, from V!"
8486 await v.greetAsync('isolate'); // same, off the UI isolate
@@ -97,6 +99,56 @@ final view = v.FractalView(width: 800, height: 600, maxIter: 500);
9799 final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888
98100 ```
99101
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+
100152 ## Is V faster than Dart?
101153
102154 For the numeric kernel in the example: **no, not meaningfully.** Measured at
@@ -126,8 +178,16 @@ generate the raw bindings than hand-write them.
126178 Verified on Linux with V 0.5.2, Dart 3 and Flutter 3.47: host build, string
127179 round-trip, isolate dispatch, 900k-call memory stability, and the example app
128180 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.
131191
132192 ## Layout
133193
@@ -136,9 +196,15 @@ src/vflutter.v the V source — the only file you normally edit
136196 src/vflutter.h C declarations (ffigen input, Xcode input)
137197 src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows
138198 lib/vflutter_ffi.dart the Dart API callers use
199+lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop
139200 ios/, macos/ pre-generated C + podspec (static archive)
140201 android/build.gradle NDK build via externalNativeBuild
141202 tool/build.sh host build + export smoke test
142203 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
143209 example/ Flutter app exercising the bridge (see example/README.md)
144210 ```
@@ -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 explorer68 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 suite70 just test # analyze + the example's test suite
70 just bench # V vs Dart timings for the same frame71 just bench # V vs Dart timings for the same frame
71 just --list # everything else72 just --list # everything else
@@ -79,6 +80,7 @@ elsewhere.
79 ```dart80 ```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); // 4284 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 isolate86 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); // RGBA888899 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 at154 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, string178 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 app179 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 path180 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 ## Layout192 ## 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/Windows197 src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows
138 lib/vflutter_ffi.dart the Dart API callers use198 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 externalNativeBuild201 android/build.gradle NDK build via externalNativeBuild
141 tool/build.sh host build + export smoke test202 tool/build.sh host build + export smoke test
142 tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the V source203 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:
1818 - platform: linux
1919 create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
2020 base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
21+ - platform: web
22+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
23+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
2124
2225 # User provided section
2326
@@ -18,6 +18,9 @@ migration:
18 - platform: linux18 - platform: linux
19 create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da119 create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
20 base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da120 base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
21+ - platform: web
22+ create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
23+ base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
21 24
22 # User provided section25 # User provided section
23 26
modified example/analysis_options.yaml +1 -0
@@ -13,6 +13,7 @@ analyzer:
1313 exclude:
1414 - build/**
1515 - linux/**
16+ - web/**
1617 errors:
1718 # The headless scripts in lib/ are command-line tools whose entire output
1819 # 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 output18 # 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> {
6161 int _lastMs = 0;
6262 int _tiles = 16;
6363 _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';
6469
6570 @override
6671 void initState() {
6772 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+ }
7098 }
7199
72100 @override
@@ -81,6 +109,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
81109 /// outright would leave the view mutated but never drawn, so instead the
82110 /// latest request is remembered and serviced once the in-flight frame lands.
83111 Future<void> _render() async {
112+ if (!await _ensureReady()) return;
84113 if (_rendering) {
85114 _renderQueued = true;
86115 return;
@@ -166,6 +195,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
166195 /// Renders the identical frame in V and in Dart on this isolate, so the
167196 /// comparison is like-for-like rather than V-plus-parallelism vs Dart.
168197 Future<void> _runBenchmark() async {
198+ if (!await _ensureReady()) return;
169199 setState(() {
170200 _rendering = true;
171201 _benchmark = null;
@@ -187,6 +217,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
187217 setState(() {
188218 _rendering = false;
189219 _benchmark = _Benchmark(
220+ unit: _unit,
221+ parallel: v.supportsIsolateParallelism,
190222 vSerialMs: swV.elapsedMilliseconds,
191223 vParallelMs: swP.elapsedMilliseconds,
192224 dartMs: swD.elapsedMilliseconds,
@@ -273,7 +305,20 @@ class _ExplorerPageState extends State<ExplorerPage> {
273305 child: Stack(
274306 fit: StackFit.expand,
275307 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)
277322 RawImage(image: _image, fit: BoxFit.fill)
278323 else
279324 const Center(child: CircularProgressIndicator()),
@@ -283,7 +328,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
283328 child: _Chip(
284329 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · '
285330 '$_lastMs ms · '
286- '${_tiles > 1 ? "$_tiles isolates" : "1 isolate"}',
331+ '${_tiles > 1 ? "$_tiles ${_unit}s" : "1 $_unit"}',
287332 ),
288333 ),
289334 if (_rendering)
@@ -338,7 +383,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
338383 ),
339384 ),
340385 _Labelled(
341- label: 'isolates',
386+ label: '${_unit}s',
342387 value: '$_tiles',
343388 child: Slider(
344389 value: _tiles.toDouble(),
@@ -381,10 +426,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
381426 child: Padding(
382427 padding: const EdgeInsets.all(14),
383428 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.',
388441 style: theme.textTheme.bodySmall,
389442 ),
390443 ),
@@ -401,12 +454,20 @@ class _Benchmark {
401454 required this.vParallelMs,
402455 required this.dartMs,
403456 required this.tiles,
457+ required this.unit,
458+ required this.parallel,
404459 });
405460
406461 final int vSerialMs;
407462 final int vParallelMs;
408463 final int dartMs;
409464 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;
410471 }
411472
412473 class _BenchmarkCard extends StatelessWidget {
@@ -427,17 +488,23 @@ class _BenchmarkCard extends StatelessWidget {
427488 Text('same frame, three ways',
428489 style: theme.textTheme.titleSmall),
429490 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'),
431492 _Row(
432- label: 'V, ${result.tiles} isolates',
493+ label: 'V, ${result.tiles} ${result.unit}s',
433494 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'),
435496 const Divider(height: 20),
436497 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.',
441508 style: theme.textTheme.bodySmall,
442509 ),
443510 ],
@@ -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 @override70 @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 @override100 @override
@@ -81,6 +109,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
81 /// outright would leave the view mutated but never drawn, so instead the109 /// 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 the195 /// 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 else323 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 differBinary 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 differBinary 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 differBinary 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 differBinary 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 differBinary 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"
77 flutter := flutter_bin / "flutter"
88 lib_path := justfile_directory() / "build"
99
10+# Port for `just web`. Override with WEB_PORT=9000 just web
11+web_port := env_var_or_default("WEB_PORT", "8080")
12+
1013 _default:
1114 @just --list
1215
@@ -25,6 +28,37 @@ build:
2528 gen-ios:
2629 ./tool/gen_ios_sources.sh
2730
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+
2862 # Analyze and test the example. Needs the host library for the FFI calls.
2963 test: build
3064 cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze
@@ -53,7 +87,7 @@ release: build
5387 cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build linux --release
5488
5589 # Everything CI would run.
56-ci: build smoke memcheck test bench
90+ci: build smoke memcheck test bench test-wasm parity check-web
5791
5892 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 --list14 @just --list
12 15
@@ -25,6 +28,37 @@ build:
25 gen-ios:28 gen-ios:
26 ./tool/gen_ios_sources.sh29 ./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: build63 test: build
30 cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze64 cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} analyze
@@ -53,7 +87,7 @@ release: build
53 cd example && PATH="{{flutter_bin}}:$PATH" {{flutter}} build linux --release87 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 bench90+ci: build smoke memcheck test bench test-wasm parity check-web
57 91
58 clean:92 clean:
59- rm -rf build example/build93+ 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);