Skip to content

WASM and on-device

The CargoForge-C engine is a single library written in pure C99. This chapter explains how that one codebase crosses the boundary from a native command-line tool to a WebAssembly module runnable in any browser — and from there, how the same compiled artifact can be embedded inside iOS and Android applications. Understanding this matters because it shows that "write once, compile everywhere" is not a slogan in C; it is a mechanical property of the language.

The mental model 🧠

The same C source that builds the command-line tool can be compiled to run inside a web browser — and from there, inside a phone. That is not a trick; it is a property of C. The language compiles to whatever target the toolchain points at: native code for your laptop, WebAssembly for the browser (via emcc), a static library for iOS or Android. One codebase, many machines — "write once, compile everywhere" is mechanical here, not marketing.

Running the engine as WASM has a real payoff beyond novelty: the whole stability calculation happens on the device, in the browser's sandbox, with no server round-trip. A port planner's manifest never leaves their laptop — it is private by construction and works offline. And because CargoForge has zero external dependencies (only the C standard library and libm, both of which the WASM toolchain supplies), there is nothing to port and nothing to break; the same engine you tested under ASan is the one running in the tab.

One C99 source compiles to native, WebAssembly, and mobile targets Because the engine is pure C99 with zero external dependencies, the same source compiles to a native binary for a laptop, to WebAssembly that runs in any browser offline and private, and to a static library embeddable in iOS and Android apps. CargoForge engine pure C99 · zero deps cc emcc ndk / xcode native binaryyour laptop / server .wasm — in the browseroffline · private · no server mobile libraryembedded in iOS / Android

What this actually means (plain English)

No jargon — here's what the ideas in this lesson actually mean, and why they matter.

  • WebAssembly (WASM) = "a tiny, safe virtual machine that any browser can run" — the browser has its own stack, its own heap, and its own arithmetic unit, and it turns out that's almost exactly what emcc already produces from C source, so CargoForge-C ports to it without touching a single line of the engine.
  • emcc / Emscripten = "a C compiler whose target happens to be the browser instead of a CPU" — it takes the same LIB_SRCS that gcc compiles for Linux and emits cargoforge.wasm + a JavaScript glue file that loads it.
  • Exported functions = "the eight C symbols that JavaScript is allowed to call across the JS/WASM boundary" — everything else in the engine is invisible to JS; _malloc and _free are included so the browser can write a ship-config string into WASM's linear memory before handing the pointer to _cargoforge_load_ship_string.
  • ALLOW_MEMORY_GROWTH=1 = "let the WASM heap expand at runtime if it runs out" — without this flag, parsing a large cargo manifest via parse_cargo_list or a multi-row hydrostatic table via parse_hydro_table could silently exhaust the default 16 MB and crash.
  • MODULARIZE=1 = "wrap the generated JS in an async factory function instead of dumping it on window" — this makes CargoForge() safe to import in a React or Vue bundle without polluting the global scope, and it correctly reflects that WASM instantiation is always asynchronous.
  • _string loader variants = "entry points that accept raw content instead of a filename" — because WASM has no real filesystem, cargoforge_load_ship_string and cargoforge_load_cargo_string accept the file's text directly, write it to a temp file internally via mkstemp, and then hand that descriptor to the same parser that the CLI uses.
  • Native mobile path (iOS / Android) = "compile the same LIB_SRCS with a different compiler target and wrap with a thin language bridge" — iOS uses a Swift bridging header over libcargoforge.h; Android uses JNI; the C engine itself is unchanged in both cases.

Why it matters: if the WASM build omits an exported function or the heap can't grow, the browser-based loading-plan tool silently fails with no result — getting EXPORTED_FUNCTIONS, ALLOW_MEMORY_GROWTH, and the _string entry points right is what makes the engine actually usable outside the command line.


What WebAssembly is, and why C maps to it cleanly

WebAssembly (WASM) is a binary instruction format that browsers can execute at near-native speed inside a sandboxed virtual machine. It is not JavaScript. A browser that supports WASM has a compact, typed stack machine — registers, linear memory, integer and floating-point arithmetic — and it turns out that this is almost exactly what a C compiler already produces when it targets conventional hardware.

The key insight: a C compiler does not generate code that depends on a specific operating system or CPU. It generates code that depends on a specific abstract machine — one with a stack, a heap, arithmetic, and function calls. WASM is another instance of that abstract machine. All Emscripten (the C-to-WASM toolchain) does is retarget the same compilation pipeline to emit WASM bytecode instead of x86-64 or ARM machine code.

Because CargoForge-C was written as a library (libcargoforge.c plus its dependencies) with no OS-specific code in the core engine — no fork, no pthread, no platform ioctl — the engine ports to WASM without a single line of source change.

The wasm Makefile target

The wasm target in the Makefile captures the entire build in one emcc invocation:

wasm: $(HDRS)
    @command -v emcc >/dev/null 2>&1 || { echo "Emscripten (emcc) not found. Install: https://emscripten.org"; exit 1; }
    mkdir -p wasm
    emcc -O3 -std=c99 -Iinclude \
        -s EXPORTED_FUNCTIONS='["_cargoforge_open","_cargoforge_close","_cargoforge_load_ship_string","_cargoforge_load_cargo_string","_cargoforge_optimize","_cargoforge_result_json","_cargoforge_version","_malloc","_free"]' \
        -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap","UTF8ToString"]' \
        -s ALLOW_MEMORY_GROWTH=1 \
        -s MODULARIZE=1 \
        -s EXPORT_NAME=CargoForge \
        -o wasm/cargoforge.js \
        $(LIB_SRCS)
    @echo "=== WASM build complete: wasm/cargoforge.js + wasm/cargoforge.wasm ==="

The output is two files:

  • wasm/cargoforge.wasm — the compiled engine, in binary WASM format.
  • wasm/cargoforge.js — a JavaScript loader ("glue code") generated by Emscripten that instantiates the WASM module, sets up its linear memory, and exposes the exported C functions to JavaScript callers.

What each flag does

Flag Meaning
-O3 Full optimisation — same level as the native build
-std=c99 -Iinclude Same language standard and header path as gcc
EXPORTED_FUNCTIONS C symbols that survive the linker's dead-code elimination and are visible to JavaScript; each name is prefixed with _ (C name-mangling convention for Emscripten)
EXPORTED_RUNTIME_METHODS Emscripten helper utilities: ccall/cwrap wrap C calls from JS; UTF8ToString converts a C char * pointer in WASM memory to a JavaScript string
ALLOW_MEMORY_GROWTH=1 The WASM linear memory is allowed to grow at runtime via realloc-equivalent calls; without this, large cargo lists or hydrostatic tables could run out of the initial heap
MODULARIZE=1 Wraps the generated JS in a factory function rather than polluting the global scope — essential for embedding in a React or Vue app
EXPORT_NAME=CargoForge The name of the JavaScript factory: CargoForge() returns a promise that resolves to the loaded module

Notice that $(LIB_SRCS) — not $(CLI_SRCS) — is passed to emcc. The CLI, server, and visualisation code (cli.c, server.c, visualization.c) are excluded. They depend on POSIX socket APIs and ANSI terminal codes that have no meaningful WASM equivalent. The engine itself — parser, analysis, placement, constraints, IMDG, tanks, longitudinal strength — compiles cleanly.

The eight exported functions

Only eight C symbols are exported to JavaScript. They are exactly the public API of libcargoforge.h:

_cargoforge_open            → CargoForge *   allocate an opaque handle
_cargoforge_close           → void           free it
_cargoforge_load_ship_string  → int          parse ship config from a string
_cargoforge_load_cargo_string → int          parse cargo manifest from a string
_cargoforge_optimize        → int            place cargo + run analysis
_cargoforge_result_json     → const char *   return JSON result (pointer into WASM memory)
_cargoforge_version         → const char *   library version string
_malloc / _free             → void *         expose the allocator so JS can allocate strings
                                             in WASM memory before passing them to C

Note

_malloc and _free are exported not to let JavaScript manage arbitrary memory, but to let it write a ship config string into WASM's linear memory and then pass a pointer to _cargoforge_load_ship_string. This is the standard pattern for crossing the JS/WASM boundary with variable-length data.

A minimal JavaScript workflow looks like this:

const cf = await CargoForge();          // instantiate the WASM module
const handle = cf._cargoforge_open();   // allocate an engine handle

// write a ship config string into WASM memory
const shipCfg = "length_m=200\nwidth_m=32\n...";
const ptr = cf._malloc(shipCfg.length + 1);
cf.HEAPU8.set(new TextEncoder().encode(shipCfg + '\0'), ptr);
cf._cargoforge_load_ship_string(handle, ptr);
cf._free(ptr);

// same pattern for cargo manifest, then:
cf._cargoforge_optimize(handle);
const jsonPtr = cf._cargoforge_result_json(handle);
const result = cf.UTF8ToString(jsonPtr);   // C char * → JS string

cf._cargoforge_close(handle);

The UTF8ToString runtime helper handles the pointer-to-string conversion: it walks WASM linear memory byte by byte from jsonPtr until it hits a null terminator, copying the bytes into a JavaScript string.

Why _string variants exist

The libcargoforge.c API has two loaders for each input type: cargoforge_load_ship (takes a filename) and cargoforge_load_ship_string (takes a const char * containing the file contents directly). The _string variants exist precisely for environments — like WASM — where the filesystem is not available or not appropriate. Internally, cargoforge_load_ship_string writes the string to a temporary file via mkstemp, calls the file-based parser, then unlinks the temp file. This lets the parser code remain unchanged; only the entry point differs.

Tip

This is a general design principle: isolate filesystem access to a thin entry-point layer. The parser functions themselves only read from a file descriptor — they don't care how that file appeared. Swapping the source (real file vs. mkstemp) is purely a one-liner at the API boundary.

ALLOW_MEMORY_GROWTH=1 and the heap

WASM modules start with a fixed block of linear memory (typically 16 MB by default in Emscripten). CargoForge-C allocates cargo arrays with malloc / realloc in parse_cargo_list, hydrostatic tables in parse_hydro_table, and tank configs in parse_tank_config. For a large manifest — hundreds of cargo items, a multi-row hydrostatic table — the initial 16 MB could be exhausted. ALLOW_MEMORY_GROWTH=1 tells the WASM runtime to request additional memory pages from the browser when malloc would otherwise fail. The tradeoff is a small performance overhead on memory growth events and the loss of SharedArrayBuffer compatibility (shared memory cannot grow dynamically). For CargoForge's interactive use case — run once per loading plan — this is the right choice.

How the same engine reaches iOS and Android

WASM is not the only path. Because the engine is a standard C library (libcargoforge.a / libcargoforge.so), it can be compiled natively for mobile targets:

  • iOS: Apple's toolchain (clang with -target arm64-apple-ios) compiles the same LIB_SRCS into a static .a that is linked into a Swift or Objective-C app. The Swift layer calls the C functions through a bridging header that imports libcargoforge.h.
  • Android: The Android NDK provides a cross-compiling clang for arm64-v8a and x86_64 targets. A JNI (Java Native Interface) wrapper marshals calls between Kotlin/Java and the C functions — the same eight- function API, the same char * JSON result.

The WASM approach trades raw performance for zero-install deployment (the engine runs in any browser with no app to install). The native mobile approach gives full CPU speed and access to device sensors (GPS for port-location context) at the cost of platform-specific build steps.

                    ┌─────────────────────────┐
                    │   LIB_SRCS (C99 source) │
                    │  parser, analysis,       │
                    │  placement, IMDG, …      │
                    └──────────┬──────────────┘
          ┌────────────────────┼────────────────────┐
          │                    │                    │
    gcc / clang            emcc                 NDK clang
    (native)           (Emscripten)            (Android)
          │                    │                    │
    libcargoforge.a     cargoforge.wasm +      libcargoforge.a
    cargoforge (CLI)    cargoforge.js          (JNI wrapper)
          │                    │                    │
    Linux/macOS         Browser / PWA          iOS / Android
    server              React / Vue app        native app

The C99 source at the top is compiled exactly once per platform — no conditional compilation, no platform abstraction layer, no preprocessor gymnastics — because the engine was written against the C standard library only, not against any OS API.

What MODULARIZE=1 means for web integration

Without MODULARIZE=1, Emscripten would attach the module to window.CargoForge immediately on script load. With it, cargoforge.js exports a factory function:

import CargoForge from './wasm/cargoforge.js';

async function runAnalysis(shipConfig, cargoManifest) {
  const cf = await CargoForge();   // returns Promise<Module>
  // ... use cf._cargoforge_open() etc.
}

This makes the module safe to import in bundled applications (Webpack, Vite, esbuild) without global-namespace pollution and without forcing synchronous WASM loading — WASM instantiation is always asynchronous in browsers.

Warning

The cargoforge.js glue file and cargoforge.wasm binary must always be served together from the same directory. The JS loader fetches the .wasm by a relative URL at runtime. Moving one without the other causes a network error at instantiation time.

Recap

  • The make wasm target invokes emcc on LIB_SRCS — the engine only, no CLI or server — and produces wasm/cargoforge.js and wasm/cargoforge.wasm.
  • Eight C functions are exported; JavaScript crosses the boundary using _malloc/_free to pass strings and UTF8ToString to read the JSON result.
  • ALLOW_MEMORY_GROWTH=1 lets the WASM heap expand to handle large cargo lists and hydrostatic tables at runtime.
  • MODULARIZE=1 wraps the module in an async factory, making it safe to import in React, Vue, or any modern bundler.
  • The same LIB_SRCS compile to native static libraries for iOS and Android without any source changes, because the engine was written against the C standard library only.
  • The _string loader variants (cargoforge_load_ship_string, etc.) are the right entry points for WASM: they accept content directly without relying on a filesystem.

Check yourself

What property of C specifically makes 'compile once, run on a laptop, a browser, or a phone' possible?

C compiles to whatever machine-code target the toolchain points at — native for a laptop, WebAssembly via Emscripten for a browser, a static library for iOS/Android. The same zero-dependency source is the input to all three toolchains; nothing about the physics code itself has to change.

Beyond novelty, what's the real practical benefit of running the stability engine as WASM in a browser?

The whole calculation happens on the user's own device, inside the browser's sandbox — no server round-trip, no manifest data ever leaving the user's machine, and it keeps working offline.

Next: Lab 11 - Link the library.