Skip to content

Compile the full engine to WebAssembly - #59

Merged
beetlebugorg merged 35 commits into
mainfrom
feat/wasm-build
Aug 24, 2026
Merged

Compile the full engine to WebAssembly#59
beetlebugorg merged 35 commits into
mainfrom
feat/wasm-build

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

zig build wasm-engine builds the complete C ABI — bake, chart, compose, style, raster — as one wasm32-wasi reactor module. A browser can bake charts and serve tiles with no server.

Lua and libtess2 keep their setjmp/longjmp error paths. Those objects compile per file with the wasm exception-handling feature plus clang's sjlj lowering, and a vendored copy of wasi-libc's sjlj runtime supplies the helpers. The target's own feature set stays default: with the feature enabled target-wide, zig 0.16 compiles its own copy of that runtime into libc, and that compile crashes. Every current browser and node implements the instructions this emits.

Engine changes for the target:

  • filemap reads the file into memory on wasi; there is no mmap.
  • The pmtiles reader lock is a no-op on wasi.
  • Thread fan-outs gate on builtin.single_threaded at comptime and fall back to their serial paths.
  • SQLite builds with THREADSAFE=0 on wasm.
  • capi's time(3) binding uses time_t; c_long is 32-bit on wasm32 and wasm-ld rejects the mismatch against libc.

JS bindings, in bindings/wasm/:

  • wasi-shim.mjs — a dependency-free WASI host for the browser, with a writable in-memory file tree.
  • tile57.mjs — a wrapper over the exports: allocation, C strings, out-parameters, error decode.
  • gpu-renderer.mjs — WebGPU over the engine's draw-ready GPU scenes. The WGSL is a port of the reference shaders in shaders/; buffers upload once per scene and pan/zoom redraws from uniforms alone.
  • engine-worker.mjs — the engine in a Web Worker, one call per RPC message, so the page stays live through long bakes.
  • demo.html — a full-screen chartplotter: drop .000 cells or an exchange-set .zip, drag to pan, wheel to zoom, per-cell bake progress. WebGPU when the browser has it, PNG views otherwise.
  • engine-smoke.mjs — the pipeline under node's WASI host.

Verified on real NOAA cells, under node and under the browser shim: bake US5BDRAB and US5BDRBB from S-57, compose them, fetch a vector tile, render correct S-52 views. zig build, zig build wasm, and zig build test are unchanged and pass.

The complete C ABI — bake, chart, compose, style, raster — builds as one
wasm reactor module, so a browser chartplotter can bake charts and serve
tiles with no server.

Lua and libtess2 keep their setjmp error paths: those objects compile
per-file with -mexception-handling plus clang's sjlj lowering, and the
vendored wasi-libc runtime (src/portray/wasm_sjlj_rt.c) supplies the
helpers. The target's own feature set stays default, so Zig's wasi-libc
build never compiles its own (broken in zig 0.16) copy.

Portability gates: filemap reads instead of mmap on wasi, the pmtiles
reader lock is a no-op there, every thread fan-out comptime-gates on
builtin.single_threaded, SQLite drops to
THREADSAFE=0, and capi's time(3) binding uses time_t (64-bit on wasi).

bindings/wasm/engine-smoke.mjs drives the real pipeline under node's
WASI: bake one S-57 cell, open the archive from bytes, fetch a vector
tile, render a PNG view.
…demo

wasi-shim.mjs is a dependency-free wasi_snapshot_preview1 host for the
browser: a read-only in-memory file tree preopened at one path, the
clock, randomness, and stdout/stderr to the console — the full import
surface of tile57-engine.wasm. tile57.mjs wraps the exports one-to-one
(linear-memory allocation, C strings, out-params, tile57_error decode).
demo.html fetches S-57 cells over HTTP, bakes them, composes them, and
renders every pan/zoom view inside the page.

The shim path is exercised end to end under node (same code, no
node:wasi): bake two cells, compose, tile, PNG.
The engine's zip bake writes per-chart archives, so the shim's file tree
now supports create, write, rename, and directory ops. bakeZip wraps
tile57_bake_zip: one call bakes a dropped exchange set into the tree.
gpu-renderer.mjs draws the engine's GPU scenes with WebGPU. Its WGSL is
a port of the reference shaders in shaders/ over the same vertex, quad,
and uniform layouts. The engine batches the ranges (tile57_gpu_batch);
the renderer uploads the buffers once per scene and redraws from
uniforms alone, so pan and zoom stay live between scene rebuilds.

tile57.mjs grows the GPU surface: scene build and decode, batch, the
sprite and SDF-glyph atlas bakes, the colortables, and the ABI layout
check.

demo.html is now a full-screen chartplotter: drop .000 cells or an
exchange-set .zip, drag to pan, wheel to zoom, HUD, zoom and fullscreen
controls over the map. WebGPU when the browser has it; PNG views
otherwise (?png=1 forces the fallback). A pre-module script catches
drops from first paint — an uncancelled drop navigates the tab while
the engine is still compiling.

The shim resolves absolute paths against the preopen root: the engine
passes some opens that way, and the zip bake's archive writes hit it.
A bake holds the CPU for seconds and a district zip for minutes; on the
main thread the page froze and no loader could even animate. The engine
now lives in engine-worker.mjs and the page drives it one call per RPC
message, so the map, the HUD, and the loader stay live while it works.

The loader shows determinate progress: a dropped zip is listed
(tile57_zip_list), then extracted (tile57_zip_extract) and baked one
cell at a time — N of M with the cell name, for plain cells and zip
members alike. zip_extract creates no directories, so the worker
pre-creates each out path's parents in the shim tree (MemFS.mkdirs).

The GPU renderer no longer holds an engine handle: it takes the atlas
assets at create and plain draw-ready data per scene, so the engine can
stay in the worker while the device and buffers live on the page. When
WebGPU is unavailable the HUD now names the reason for the PNG
fallback instead of switching silently.
navigator.gpu exists only in a secure context; a LAN IP over plain http
hides it with the same symptom as a browser without WebGPU.
The docs workflow builds the engine (ReleaseSmall: one third the
download, this workload runs just as fast), stages the demo app under
static/demo-app/, and src/pages/demo.jsx embeds it at /demo — GitHub
Pages is https, so WebGPU is available to it. A Live Demo item joins
the navbar, and engine changes now redeploy the site so the demo always
runs the current engine. CI builds the wasm engine too, so a break
surfaces on every push, not just docs deploys.

Demo polish: the drop hint links NOAA's free ENC downloads and says
charts never leave the page, arrow keys pan and +/- zoom, an engine
that fails to start reports itself instead of spinning, and a batch's
failures land in one status line.
The chrome themes itself from the engine's own colour tables (a new
palette worker op): NODTA for the map surround, UIBCK panels with UIBDR
borders, UINFD/UINFF text, UINFB accents, UINFR for failure lines, and
the cursor readout in CURSR. The CSS fallbacks carry the same day
values, so the page looks right before the engine is up.
zig fmt re-columns the sjlj flag table. The time binding uses
std.c.time_t only where the target defines it — Windows leaves it void
in std.c, and the wasi fix had broken both Windows cross-compiles.
One wasm instance is single-threaded, so parallel baking means more
instances: bake-pool.mjs spins up N extra engine workers (sized from
the machine's cores, ?workers=N overrides) and a batch's cells fan out
across them, several at a time. A cell bake is pure — bytes in, archive
bytes out — so pool slots share nothing; the primary engine worker
keeps the charts, the compositor, and rendering, and archives open
there as bakes finish.

A dropped zip stays in the primary (it lists and extracts); each cell's
extracted files shuttle out through a new readFile op to a pool slot,
so extraction streams while bakes run wide. Pool slots close when the
batch ends — an engine instance holds linear memory wasm never returns,
and respawning one costs far less than keeping it.

worker-rpc.mjs carries the shared RPC protocol for both the primary and
the pool.
…inish

wasm i32 values cross into JS SIGNED, so once the engine's memory grew
past 2 GiB every returned pointer looked negative — each DataView write
threw, ops unwound mid-call, and a district-sized drop collapsed into a
cascade of range errors and follow-on OOMs. The wrapper now masks every
allocation (walloc: checked and >>> 0), and the shim masks every
numeric argument on the way in — the engine passes pointers into WASI
too. The GPU renderer draws the bare NODTA ground when no scene is
uploaded instead of touching missing buffers.

Memory now stays flat through a big batch: a new remove op drops files
from an engine's tree, pool slots free each cell's files as its bake
ends, the primary frees a zip cell's extracted files the same way, and
the zips themselves free when the batch closes.
Browsers deprecate the legacy 'try'; wasm-use-legacy-eh=false makes the
sjlj lowering emit try_table over exnref instead, which also needs the
reference-types feature — enabled per file the same way as
exception-handling, since zig's module-derived feature flags land last
and must be won back with raw -Xclang pairs.
…enes

Baked archives go to disk as they finish and only the view's charts
stay in the engine — the fix for a district-sized drop exhausting the
primary's 4 GiB linear memory at compose_open.

chart-library.mjs stores each archive in the browser's origin-private
file system with a metadata sidecar (the tile57_info the bake worker
now returns alongside the bytes), so a page load catalogs the library
without opening one archive, and re-drops of known cells skip the bake.
Without OPFS the session keeps archives in page memory instead. The 🗑
control clears the library.

The page now holds a catalog, not open handles. Each rebuild selects
the charts whose bounds intersect the view at a compilation scale
within ~11x of the view's (nearest few as fallback, so overscale beats
blank sea), opens the missing ones from the library, composes exactly
that subset, and evicts least-recently-used charts beyond a 32-chart
cap. The composite is keyed by the selected set and reused while the
set is stable.

Scenes also refresh DURING a gesture now: rebuilds run in the worker,
so a wheel-zoom or drag kicks a throttled rebuild and a sharper scene
lands mid-gesture instead of waiting for the debounce to settle.
Over the cap the selection order decides who draws, and it now keeps
the most GENERAL charts first: they cover the view in the fewest cells,
so a z3 view over a harbor-cell library fills with the overview band
instead of 32 arbitrary harbor cells. Under the cap the order is moot —
the engine's partition gives detailed charts precedence on overlap.
The out-of-window fallback competes every intersecting chart instead
of an arbitrary eight, and ?open=N tunes the cap.
… at 64

The library catalog needs no engine: the page lists OPFS before the
wasm even downloads, so a returning visit shows its chart count and
stored bytes immediately (and the HUD keeps showing them through
engine startup instead of flashing 'no charts'). The library listing
now carries each archive's on-disk size, the HUD shows the running
total, and newly baked charts add to it. The residency cap default
rises to 64 open charts (?open=N still overrides).
One overview chart can span an ocean; the bounds union then centres far
off the charts that matter. Charts with a footprint over ~8x the median
stay out of the fit (they still draw), so a loaded library lands
centered over its detailed cluster.
The vertex shaders draw each vertex at the world copy nearest the
camera — seamless across the antimeridian when zoomed in, but in a wide
view the seam meridian falls onto geometry and a primitive straddling
it wraps its vertices to opposite copies, tearing into full-width
horizontal streaks. Once the viewport spans a large share of a world
the renderer now disables the wrap (everything draws in its home copy).

Stored bytes come from navigator.storage.estimate() — one instant call.
The per-file sizing crawled at thousands of charts and read zero sizes
on Firefox, showing '1 kB' for a full library. clear() is one recursive
directory remove instead of thousands of per-file deletes.
The scale window alone left holes: at z4 only the overview band
qualified (21 charts open under a 64 cap) while areas those charts miss
stayed blank until deeper zoom pulled the next band in. Slots left
after the suitable window now go to out-of-window charts nearest the
view's scale — an area only a detailed chart covers shows that chart
overscaled, never a hole.

The camera persists in localStorage on every settled rebuild, and a
load with a saved view lands exactly there; the cluster fit only runs
for a first visit (and after a drop, which still jumps to what was
added).
Rotation rides the GPU-scene contract as designed: scenes stay north-up
in world space, the MVP turns the camera, and the shaders turn exactly
the map-aligned offsets by the same angle (rot_sin/rot_cos were already
wired). Shift-drag rotates about the screen centre, a two-finger pinch
zooms, twists, and pans about its midpoint, the compass control resets
north-up (its needle tracks the heading, shown in the HUD), and the
rotation persists with the saved view. All camera math — cursor
readout, pan, anchored zoom, the PNG placement — now goes through
rotation-aware screen/world transforms.

A fast pan release flicks: the camera keeps its velocity and decays
exponentially, redrawing live and settling into a rebuild.

Scene and PNG requests cover the rotated viewport's bounding box
inflated by a prefetch margin (?margin=K, default 1.6), and chart
selection uses the same box — a pan, zoom-out, or turn shows real
chart from the standing scene while a sharper one streams in. The PNG
fallback re-places its last north-up render as one CSS similarity
transform, which gives it the same live pan/zoom/rotate preview.
Half the length: the demo sits at the top behind the standard Annapolis
view (the same view the quick-start png command draws), the body says
what you can build rather than which calls exist, and the API tables
and code samples live where the docs already cover them.
bindings/wasm merges into bindings/js: the JS modules, the demo, the
wasm build entry, and the old style-only package now live in one
directory, like bindings/go. The package's main export is the full
engine (index.mjs re-exports every module and createEngine stands the
engine up in a browser or node); the style-only engine moves to the
tile57/style subpath (style.js, style.d.ts). The full-engine wasm is a
build artifact (npm run build), not a committed binary.

README: the API list names C, Zig, JavaScript, Go, and the CLI; the
screenshot drops to 140 kB; no em dashes.
The demo becomes a real chartplotter host, organized as focused modules
under bindings/js/demo/ (view, camera, gestures, chart store, importer,
mariner model, settings panel, pick) with demo.html as a slim shell.
The chrome follows the chartplotter and lookout-marine shells: the map
is the UI, round buttons float in the corners, one bottom-centre data
card carries the readout, bake progress, and messages (no more modal
loader), panels are caret popovers, toasts stack above the card, and
the ui tokens re-skin everything for day, dusk, and night. A first
visit gets a welcome card, and the docs workflow bundles a NOAA sample
harbor (US5MD12M) it can load with one tap.

Mariner settings run end to end: tile57.mjs marshals tile57_mariner
(the node test proves the layout against the engine's own defaults and
a night render differing from day), the worker passes settings through
the png and gpu-scene ops, and the settings panel carries the options
the struct takes: detail level, depth contours in the mariner's unit,
the soundings tri-state, symbol and text choices, dates. The scheme
control cycles day, dusk, night: ui tokens flip, the sprite atlas
re-bakes for the palette, and scenes rebuild with the new settings.

A tap picks. The C query API reports through a callback no JS host can
provide, so wasm_root grows tile57_wasm_query: the callback lives in
the module and the features return as one JSON array. The worker pairs
each feature with its decoded tile57_s57_report; pick-model.mjs ranks
the set (a port of lookout-marine's pick.zig rules) and the report
card presents it lookout's way, chips for the set, the operative fact
as the title, the raw S-57 one fold away.
The S-52 canon (safety contour 10 m) suits SOLAS drafts; a sailboat
draws about 2 m, and a 10 m safety contour paints most of a harbor as
unsafe. The demo defaults to shallow 2 m, safety 3 m, deep 10 m, safety
depth 3 m; stored settings still win.
The recreational convention on US charts; stored values stay metric
under the hood and the mariner's own setting still wins.
The rebuild reads gpu.halo for the SDF text pass and the clear colour;
an un-awaited setScheme left both one scheme behind, so labels held the
old palette until the next camera move rebuilt again.
The pick cursor; the grabbing hand shows only during an actual drag.
Annapolis and the bay approaches at 1:40,000. The previous cell was a
thin harbor slice that did not even contain the Annapolis point.
The harbor at 1:12,000 (US5MD1MC), the Severn and the bay approaches
at 1:40,000 (US5MD13M, US5MD12M), and the band-3/4 context (US4MD1DD,
US3EC08M) so zooming out still shows chart. One NOAA cell was a slice;
this set quilts the whole area at every zoom.
The FullLightLines bake portrays a sectored light's legs twice (sect 0,
the 25 mm stubs; sect 1, the full-length pass) and filters them in the
MapLibre style. The engine's own render paths resolve display variants
in resolve.zig, which gated bnd and pts but not sect, so both passes
drew and full sector lines showed whichever way the switch stood. The
composed replay also dropped the sect tag on decode. Validated on
US5MD1LC: Thomas Point Shoal Light draws stubs with the switch off and
full legs with it on.
US5MD1MD carries the eastern half of the deep-draft anchorage and was
missing, so the sample cut it at the harbor cell's seam; the bundle now
holds the central 1:12,000 row (MB, MC, MD), the Thomas Point cell
(LC), and the 1:40,000/45,000/200,000 context. The anchorage view now
renders byte-identical to the full Maryland library.

The README lists quilting as its own capability.
The pick report follows lookout-marine's callout: the pick's objects
stay in sight as a left column (the main data in each row, the object
on show held selected, the chart's M_* notes pinned at the column's
floor), and the detail holds the decoded report with the provenance
line and the S-57 source fold at ITS floor, so the controls keep their
place while the rows scroll. A copy control puts the raw payload on
the clipboard. Esc closes the settings drawer first, then the report.
The text and pictures a chart's features point at now survive import:
the zip path stores every non-cell sibling (the TXTDSC notes, PICREP
pictures) in the library beside the chart's archive (OPFS, with a
session fallback), and a file row in the pick report opens its content
inline - text as a scrollable block, web-displayable pictures as an
image, TIFF named as such. Clearing a chart clears its files.

The book, copy, and document glyphs become inline SVGs: the exotic
codepoints read as tofu on fonts that lack them, which is what the
notes rows and data-quality entries were showing.
@beetlebugorg
beetlebugorg merged commit edcac13 into main Aug 24, 2026
5 checks passed
@beetlebugorg
beetlebugorg deleted the feat/wasm-build branch August 24, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant