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.
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
emccalready 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 sameLIB_SRCSthatgcccompiles for Linux and emitscargoforge.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;
_mallocand_freeare 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 viaparse_cargo_listor a multi-row hydrostatic table viaparse_hydro_tablecould silently exhaust the default 16 MB and crash.MODULARIZE=1= "wrap the generated JS in an async factory function instead of dumping it onwindow" — this makesCargoForge()safe to import in a React or Vue bundle without polluting the global scope, and it correctly reflects that WASM instantiation is always asynchronous._stringloader variants = "entry points that accept raw content instead of a filename" — because WASM has no real filesystem,cargoforge_load_ship_stringandcargoforge_load_cargo_stringaccept the file's text directly, write it to a temp file internally viamkstemp, and then hand that descriptor to the same parser that the CLI uses.- Native mobile path (iOS / Android) = "compile the same
LIB_SRCSwith a different compiler target and wrap with a thin language bridge" — iOS uses a Swift bridging header overlibcargoforge.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 (
clangwith-target arm64-apple-ios) compiles the sameLIB_SRCSinto a static.athat is linked into a Swift or Objective-C app. The Swift layer calls the C functions through a bridging header that importslibcargoforge.h. - Android: The Android NDK provides a cross-compiling
clangforarm64-v8aandx86_64targets. A JNI (Java Native Interface) wrapper marshals calls between Kotlin/Java and the C functions — the same eight- function API, the samechar *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 wasmtarget invokesemcconLIB_SRCS— the engine only, no CLI or server — and produceswasm/cargoforge.jsandwasm/cargoforge.wasm. - Eight C functions are exported; JavaScript crosses the boundary using
_malloc/_freeto pass strings andUTF8ToStringto read the JSON result. ALLOW_MEMORY_GROWTH=1lets the WASM heap expand to handle large cargo lists and hydrostatic tables at runtime.MODULARIZE=1wraps the module in an async factory, making it safe to import in React, Vue, or any modern bundler.- The same
LIB_SRCScompile to native static libraries for iOS and Android without any source changes, because the engine was written against the C standard library only. - The
_stringloader 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.