Cross-build classic libpcre 8.45 (the library src/packages/pcre links) with emconfigure/emmake into the wasm-deps prefix and stop forcing PACKAGE_PCRE off under EMSCRIPTEN, so all pcre_* efuns exist in the browser driver. Mudlibs whose boot-critical code (e.g. simul_efuns doing ANSI handling with pcre_replace) calls these no longer fail to boot on wasm. - tools/wasm/build-deps.sh: new PCRE section following the ICU pattern (idempotent re-run guard extended, PCRE_VER override). Static, UTF-8 + Unicode properties on (the driver compiles every pattern with PCRE_UTF8), JIT off (no executable pages in wasm), default chartables (no host-run dftables needed). - src/CMakeLists.txt: drop the forced PACKAGE_PCRE OFF; FindPCRE locates the static lib through CMAKE_FIND_ROOT_PATH -> wasm-deps. - .github/actions/build-wasm: pcre-ver input feeds the deps cache key (old ICU-only caches no longer match) and PCRE_VER reaches build-deps.sh. - docs/build-wasm.md, src/wasm/README.md: pcre moved out of the absent-package lists; deps/build notes updated. Verified: wasm LPC testsuite passes with the 9 pcre efun tests now active (621 OK / 0 failed, same as native); native rebuild + testsuite unaffected. fluffos.wasm grows 3,390,869 -> 3,604,494 bytes (+209KB raw, ~0.84MB brotli over the wire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VyQCUoTo1Z93Py9aVFHQi1
16 KiB
FluffOS on WebAssembly (Emscripten)
This directory contains the WebAssembly port of the driver: the whole compiler + VM + efun stack runs inside a browser page (or node), boots a real mudlib from an in-memory filesystem, and speaks real telnet with the webpage — the page is the telnet client, the JS host is the wire.
Status: working. The driver boots the bundled testsuite/ mudlib,
accepts virtual connections through master::connect() / logon(),
processes commands (including input_to/get_char, prompts, snoop), runs
heartbeats and call_outs, and the LPC testsuite passes clean (tests of
packages that don't exist on this target skip themselves via
__PACKAGE_*__ guards). CI runs the suite under node on every PR (the
wasm job in .github/workflows/ci.yml).
This file documents the architecture. The user-facing end-to-end
workflow (deps → presets → packer → serve → embed) is
docs/build-wasm.md. Quick start:
tools/wasm/build-deps.sh # once: cross-build ICU
tools/wasm/build.sh # presets native-tools + wasm, pack testsuite
python3 -m http.server -d build-wasm/dist 8080
# open http://localhost:8080/
# package your own mudlib: tools/wasm/pack-mudlib.sh --help
1. Architecture
Three native concepts have no browser equivalent; each got a seam:
1.1 The event loop is inverted (wasm/backend_wasm.cc)
The native driver blocks forever in libevent's event_base_loop()
(backend.cc). A browser tab cannot block: the page owns the event loop.
The scheduling core (the gametick queue, add_gametick_event(), the
maintenance events) lives in the shared backend.cc; only the loop that
advances time is per-target. wasm/backend_wasm.cc implements it with a
plain wall-time priority queue and no libevent at all: the page calls
the exported fluffos_tick(now_ms) on a timer
(setInterval/requestAnimationFrame), which drains due wall-time
events (including buffered user commands, scheduled by the transports)
and advances as many gameticks as have elapsed (capped catch-up for
suspended tabs). Heartbeats, call_outs, resets, reclaims — all driver
scheduling already went through this API, so nothing above it changed.
1.2 The socket is a Transport (net/transport.h)
Each interactive_t owns a Transport — the abstract byte pipe
(write / flush / schedule_command / close). Everything above it
is transport-agnostic and compiled unchanged on every target:
comm.cc (users, command queue, input_to, prompts, snoop) and
net/telnet.cc (full telnet negotiation via libtelnet, which is pure C).
There are three implementations, selected per target at link time:
SocketTransportandWebsocketTransport(net/transport_libevent.cc, native only): bufferevent/TLS and libwebsockets, plus the listening ports, accept handler and socket read path;WasmConsoleTransport(wasm/comm_wasm.cc): outbound wire bytes go toModule.fluffos.onOutput(id, bytes)in JS; inbound bytes come from the exportedfluffos_input(id, bytes)→comm_telnet_received()— the same path a socket read takes. Connection setup (fluffos_connect()) mirrors the native accept flow:user_add(), telnet init + initial negotiations,master::connect(port),logon(). There are no listening sockets (init_user_connis a no-op).
Because the driver still emits/consumes real telnet, the page needs a
telnet client — src/www/wasm/index.html includes one that negotiates
ECHO (password masking), SGA (the driver's char-mode signal: the page
auto-switches between its line-input bar and raw keystroke streaming),
NAWS (live window size, re-reported on resize → the window_size apply)
and TTYPE (xterm-256color). Terminal emulation itself — rendering,
SGR, the alternate screen, mouse reporting, bracketed paste, keyboard
encoding — is xterm.js, vendored from npm under
src/www/vendor/ (shared with the websocket client, see
src/www/README.md) and shipped by tools/wasm/pack-mudlib.sh, which
is what makes the mudlib TUI library (testsuite/std/tui) work in the
browser end to end.
The same link-time pattern covers the other per-target singletons:
| Interface | Native | WASM |
|---|---|---|
event loop (backend.h) |
backend_libevent.cc |
wasm/backend_wasm.cc |
| connection transports | net/transport_libevent.cc |
wasm/comm_wasm.cc |
TLS (net/tls.h) |
net/tls.cc |
not linked at all — the one shared caller (sys_reload_tls) is excluded from the target in core.spec |
DNS resolver (packages/core/dns.h) |
packages/core/dns_libevent.cc |
packages/core/dns_stub.cc |
crash handler (base/internal/crash_handler.h) |
base/internal/crash_handler.cc (backward-cpp) |
wasm/crash_handler_wasm.cc |
The shared logic files contain no #ifdef __EMSCRIPTEN__; the only
platform conditionals live in net/net_compat.h (type declarations) and
one thread-capability guard in base/internal/tracing.cc.
1.3 The filesystem is a VFS (Emscripten MEMFS)
The driver's file I/O (compilation, save_object, read_file, ed, logs)
is plain POSIX + ghc::filesystem, which Emscripten maps onto its in-memory
FS transparently — zero driver changes were needed.
The mudlib is bundled with Emscripten's file_packager
(tools/wasm/build.sh stage 3): it packs testsuite/ into a single
mudlib.data image + mudlib.js loader that mounts it at /testsuite
before the runtime starts. Any mudlib can be packed the same way
(MUDLIB=/path/to/lib tools/wasm/build.sh). Writes go to MEMFS and last
for the page session; a persistent overlay (IDBFS/OPFS syncing /data,
save files etc.) is the natural next step — see §5.
2. What was removed, what was kept
| Subsystem | WASM build | Why |
|---|---|---|
| libevent | removed | replaced by host-driven tick queues |
libwebsockets, net/websocket.cc, net/ws_*.cc |
removed | the page is the client; no listening sockets |
OpenSSL, net/tls.cc |
removed | no TLS endpoint to terminate (the page/browser owns TLS); the sys_reload_tls efun does not exist on this target; 2 struct fields typedef'd via net/net_compat.h |
libtelnet, net/telnet.cc, net/msp.cc, mssp |
kept | pure C / portable; the page speaks telnet |
| ICU (uc + data) | kept (cross-built) | core string handling: grapheme iteration, charset conversion, sprintf width |
| libpcre (classic 8.x) | kept (cross-built) | pcre package efuns; plain C, JIT off (no executable pages in wasm) |
| zlib | removed | nothing on this target needs it: MCCP + the compress package are off, and the core's gzip'd file support is gated behind HAVE_ZLIB (compressed save_object degrades to a plain save; write_file flag 2 errors; reads use stdio) |
thirdparty/crypt (musl crypt) |
kept | pure C |
| backward-cpp | removed | no native unwinder in wasm |
| jemalloc | removed | dlmalloc from emscripten |
POSIX eval-limit timers (posix_timers.cc) |
auto-disabled | __linux__ only; see §5 for the planned replacement |
Package matrix (src/CMakeLists.txt forces these under EMSCRIPTEN):
| Package | State | Reason |
|---|---|---|
| core, ops, math, matrix, trim, uids, sha1, parser, contrib, develop, mudlib_stats | on | portable |
| dwlib | default off (same as native) | portable; enable with -DPACKAGE_DWLIB=ON |
| jsbridge | on (WASM only) | js_eval() / js_call() / js_export(): LPC ↔ page JavaScript in both directions (fetch, canvas/WebGL, page UIs driving the game, …) — see docs/build-wasm.md §7 and docs/driver/wasm.md |
| sockets | off | BSD sockets (LPC socket efuns) |
| compress | off | zlib efuns + MCCP make no sense against a same-page client |
| external | off | posix_spawn child processes |
| async | off | worker threads (see §5) |
| db | off | MySQL/SQLite/PG client libs |
| crypto | off | OpenSSL EVP (see §5: sha1 stays) |
| ffi | off | libffi + dlopen |
| pcre | on | libpcre 8.x cross-built into the wasm-deps prefix by tools/wasm/build-deps.sh; all pcre_* efuns work |
DNS (packages/core/dns.cc): the resolver half is a synthetic resolver
(dns_stub.cc) — resolve() keeps the native call/return shapes but
completes on the next tick with 127.0.0.1 (or the input echoed back
when it is already numeric); the address-cache half
(query_ip_name/query_ip_number) is compiled unchanged.
3. Build system
Two CMake presets, because the codegen tools (make_func,
build_applies, make_options_defs) execute at build time and must be
native:
# stage 1 -- native host tools (preset: native-tools)
cmake --preset native-tools && cmake --build --preset native-tools
# stage 2 -- cross build (preset: wasm, through the emscripten wrapper)
emcmake cmake --preset wasm && cmake --build --preset wasm
tools/wasm/build.sh runs both presets and packages the bundled
testsuite; tools/wasm/pack-mudlib.sh packages any mudlib (see
docs/build-wasm.md).
3.1 Cross-built dependency (ICU)
The build expects static wasm ICU under one prefix
(-DFLUFFOS_WASM_DEPS, default /opt/wasm-deps): libicuuc.a,
libicudata.a + headers. tools/wasm/build-deps.sh builds all of
it; for the record, the ICU cross-compile quirks it handles:
- copy
config/mh-linuxtoconfig/mh-unknown(ICU doesn't know the emscripten triple); pkgdatacannot produce a wasm object for the data archive — generate it as C instead with the host build'sgenccode(genccode -e icudt74 icudt74l.dat && emcc -c ... && emar rcs libicudata.a ...);- the data archive is trimmed with
icupkg: the stock archive is ~30MB, but the driver only pulls break-iterator data from it (grapheme + line breaking) — character properties and NFC are compiled into libicuuc, and the UTF-8/UTF-16/Latin-1/ASCII converters are algorithmic. The trim keepsbrkitrrules + the converter alias table (~780KB); segmentation dictionaries and table charsets (GBK, Big5, Shift-JIS, …) are dropped —string_encode()etc. raise an LPC error on those, and LPC can test__WASM__to adapt. Need a charset back? Re-runbuild-deps.shwithICU_KEEP(see the script header). (ICU_DATA_FILTER_FILEdoesn't work here: it only applies when building ICU data from source, and the-srctarball ships a prebuilt.dat.)
3.2 Link flags (see driver-web in src/CMakeLists.txt)
- exceptions (compile+link): LPC error handling is C++ exceptions;
emscripten disables catching by default. On emsdk >= 3.1.57 the build
uses
-fwasm-exceptions(native wasm EH — much faster unwinding, and supported by every current browser and node >= 18); older toolchains fall back to the JS-based-sDISABLE_EXCEPTION_CATCHING=0. -sMODULARIZE=1 -sEXPORT_NAME=createFluffOS --no-entry: the page instantiates the module and drives exported entry points; there is nomain().-sINITIAL_MEMORY=64MB -sALLOW_MEMORY_GROWTH=1 -sTOTAL_STACK=16MB: room for the compiler/VM heap up front; the heap grows on demand.-g0 --profiling-funcs: full DWARF is dropped at link (binaryen's wasm-opt asserts trying to update it at -O3, notably with wasm EH) but the function-name section is kept, so browser/node stack traces stay readable.- no
-pthread, no stack protector, no-march=native, no LTO.
4. JS embedding API
const M = await createFluffOS({ print, printErr, locateFile });
M.FS.chdir('/testsuite'); // mudlib mount point
M.fluffos = {
onOutput: (id, bytes) => {...}, // server->client wire bytes
onDisconnect:(id) => {...},
};
M.ccall('fluffos_boot', 'number', ['string'], ['etc/config.test']);
setInterval(() => M.ccall('fluffos_tick', 'number', ['number'],
[performance.now()]), 50);
const id = M.ccall('fluffos_connect', 'number', [], []);
M.ccall('fluffos_input', null, ['number','array','number'], [id, bytes, n]);
// also exported: fluffos_flag (master::flag, e.g. 'test' runs the LPC
// testsuite), fluffos_disconnect, fluffos_shutdown
5. Refactoring roadmap (remaining phases)
Delivered so far: the Transport interface + per-target implementations
(link-time dispatch, no #ifdef seams in shared code), the inverted
backend, the JS telnet bridge, presets + tools/wasm/build-deps.sh +
tools/wasm/pack-mudlib.sh, the web shell, docs
(docs/build-wasm.md), and a CI job that gates on the LPC testsuite
running inside the wasm driver. The follow-up phases, in recommended
order:
- Eval limit.
set_eval()currently warns "platform doesn't support eval limit": a runaway LPC loop hangs the tab. Replace the SIGVTALRM/posix-timer scheme with a deadline check (emscripten_get_now() > deadline → outoftime = 1) polled in the interpreter's existing backward-branch/apply hooks. This also benefits macOS/Windows, which have no eval limit today either. - Persistent storage. Mount an IDBFS (or OPFS) overlay over the
mudlib's write paths (
/data, save files, logs) andFS.syncfs()on a timer + onvisibilitychange, so player data survives page reloads. - More packages. PCRE is done (cross-built by
tools/wasm/build-deps.sh, package on).cryptocan come back once OpenSSL's libcrypto is cross-built (or be rebased onto smaller portable digests).asynccan return as synchronous fallbacks (the efuns' contracts allow completing "later" on the next tick).dbwith SQLite is feasible (sqlite3 compiles to wasm famously well) and would give mudlibs a real database in the browser. - LPC sockets over WebSocket/WebRTC. If a mudlib needs outbound
socket_efuns, they can be tunneled throughWebSocketobjects on the JS side with the same bridge pattern as the console (bytes in / bytes out per socket id). Inter-mud protocols would then work. - Size/latency budget — done. ICU data is trimmed to brkitr only
(§3.1), DWARF is dropped at link (§3.2), MCCP/compress are off:
fluffos.wasmis ~3.6MB raw (libpcre included, +~210KB) and ~0.8MB brotli (~1.1MB gzip) over the wire, plus ~110KB of JS glue. Remaining knobs if more is ever needed: strip the name section (--profiling-funcscosts ~0.4MB raw for readable stack traces) and-Ozon the code (~1.7MB of the raw size is code). - Native loopback transport. The
Transportinterface makes an in-process console user possible on the native driver too (aPipeTransport), which would let driver tests exercise the full login/command pipeline without sockets.
6. Known limitations (current state)
- No eval limit:
while(1);in LPC blocks the tab (phase 1 above). - No real DNS:
query_ip_number()reports 127.0.0.1 for web connections, andresolve()resolves everything to 127.0.0.1 synthetically (native callback shape, next tick). - Disabled-package efuns (
socket_*,external_start,db_*, ffi, async I/O,compress*/uncompress*) don't exist; their testsuite files skip themselves via__PACKAGE_*__guards and the suite passes clean. (PCRE efuns exist — the pcre package is on.) - No zlib: gzip'd
write_file(flag 2) raises an error, compressedsave_objectfalls back to a plain-text save, and.gzfiles are not transparently decompressed byread_file/restore_object. - Only algorithmic charsets (UTF-8/UTF-16/UTF-32, Latin-1, ASCII):
string_encode()/buffer_transcode()/set_encoding()to table charsets (GBK, Big5, …) raise an error unless the deps were built with a customICU_FILTER. LPC can#ifdef __WASM__to adapt. - MEMFS writes are per-session until phase 2 lands.
- The tab suspends timers in background: gameticks catch up (capped at 100 ticks) when the tab wakes rather than running while hidden.