Goal of this page: understand how RingScript works inside, find your way around the repository, and change the runtime with confidence.
your page (HTML/CSS) the runtime (one .wasm file)
┌─────────────────────────┐ ┌──────────────────────────────┐
│ ringscript.js (loader) │ WASI + │ bridge (Zig) │
│ · WASI shim │◄─ ringscript ─►│ · resident RingState │
│ · js_dispatch / js_give│ imports │ · eval/call/error shims │
│ · boot()/load() API │ │ · embedded ringlib map │
└─────────────────────────┘ │ Ring VM (vendored C) │
│ · compiler + VM, unmodified │
│ except 4 marked patches │
└──────────────────────────────┘
Three layers, three languages, each doing the one thing it is best at:
- The vendored Ring VM (
ringvm/) — Ring's own C source, compiled towasm32-wasiby Zig. Not a port, not a rewrite: the same compiler and VM that powerring.exe, which is why output is byte-identical to native. - The bridge (
src/bridge.zig+src/wasi_stubs.c) — a small resident layer exporting a stable C-style API (rs_init/rs_eval/rs_call/rs_last_output/…) and owning the things a browser runtime must own: the one long-livedRingState, error trapping, the input queue, the embedded file map, value printing. - The loader (
playground/ringscript.js) — the only JavaScript: a ~150 line WASI shim (clocks, randomness, stdout) plus the public API. No frameworks, no dependencies, classic script. It is hand-written, and therefore the densest source of defects per line in the project — a mistake there is silent, since the VM keeps running and merely reports something plausible.tests/wasi.jsholds it to the host's own clock, encoding and ordering rather than to its opinion of itself.
ring.eval(code) travels this path:
- The loader copies the source into wasm memory and calls
rs_eval. - If the source could open a region — it mentions
class,func,deforpackage, or keywords have been renamed — the bridge appends a region terminator: a uniquely-named, never instantiated class, closing a region that would otherwise be terminated incorrectly at end-of-eval. The name must be unique (Ring rejects a repeated class definition) and is therefore permanent, so it is emitted only when needed: every eval used to add one, and the class list grew by one per eval — unbounded, in exactly the long-lived page this runtime is for. Declaration-free evals now add nothing. The code then runs through a Ring-level shim:try eval(rs_getcode()) catch rs_reporterror(cCatchError) done. Thers_getcodehook hands the source over by reference, so no string escaping exists anywhere in the pipeline. - Output flows into a growable buffer:
see/?/putthrough theringvm_seehook; C-levelprint()through the WASIfd_writeimport, which (withcaptureStdout) re-enters the wasm to append at the exact right position. Values print through mirrors of the VM's own printers — objects asattr: valuelines, circular lists as[...] (RC:n), numbers via the livedecimals()setting. - Errors — compile or runtime — are trapped by the catch shim. The
real line number is captured at error time (a vendor patch;
catch-time state restoration would have erased it) and reported as
line N: message. The VM survives. - If the code defined
func mainand nothing ran it, the bridge runs it once — matching native Ring's end-of-program behavior, which the eval path cannot reach on its own.
ring.call rides the same machinery with generated glue:
rs_setresult(JsonEncode(F(JsonDecode(rs_getarg())))).
- No filesystem, by construction. Every
fopenin the VM is redirected (-Dfopen=rs_fopenat compile time) to a resolver over the embedded file map;fmemopengives the VM a real read-onlyFILE*over embedded bytes.loadworks; writes fail like a missing file; there is nothing to sandbox because there is nothing there.statis redirected to the same map (rs_stat), because Ring'sfexists,getfilesizeandgetpathtypeask about a file instead of opening one — without it they report that embedded files do not exist, and the idiomaticif fexists(f) ... read(f)guard skips files that are there. Directories still do not exist, truthfully. giveis a hook, not stdin. Ring routesgivethrough a replaceableringvm_givefunction; the bridge's implementation reads the eval's input queue, then asks the host live (js_give→onGive/prompt), then raises a clean error. Interactive programs stay interactive; nothing hangs.- One resident state, explicit resets.
rs_reset()is the only way the VM is recreated. Nothing implicit, ever. - The VM does not run inside itself. A
jscall/onGivehandler is invoked while Ring is running, so a handler callingring.eval(),ring.call()orring.reset()would re-enterring_state_runcodeon the state already executing. It does not nest: the rest of the outer program was discarded, its output and its error were replaced by the inner run's, andrs_callreturned the handler's value instead of the function's — silently, withok = true. A depth guard (g_running,rs_busy()) now refuses re-entry before any buffer is touched, so the outer run finishes intact and the handler gets a plain error telling it to defer. The guard is cleared in afinallyon the loader side, because a wasm trap unwinds out ofrs_evalwithout running itsdefer— and a guard left standing would refuse every later eval for the life of the page. - 8 MB wasm stack. Deep parser recursion and deeply nested lists (2000 levels verified) need more than the 1 MB default.
- Vendor purity with four exceptions. The Ring source is compiled
as-is, plus four small patches each marked
RINGSCRIPT PATCHin place and documented in VENDOR_PATCHES.md. It was seven until 2026-08-16, when a swap to Ring master retired three of them by making them upstream code — the best outcome a vendor patch can have, and the reason the count is worth watching in both directions.
ringscript/
├── build.zig wasm runtime + dev server + serve/dist steps
├── start-playground.bat double-click launcher (Windows)
├── start-playground.sh double-click launcher (macOS / Linux / BSD)
│
├── package.ring RingPM manifest (what a user downloads)
├── main.ring `ringpm run ringscript` — self-locating CLI
├── lib.ring the same operations, callable from Ring
├── cli/starter.html template used by `new`
├── bin/ prebuilt servers, ~40 KB each, COMMITTED:
│ RingPM ships one per platform, no Zig needed
│
├── src/
│ ├── bridge.zig the bridge (§2) + the embedded file map
│ ├── wasi_stubs.c fopen resolver, exact-mirror value printers,
│ │ VM accessors
│ ├── rs_json.c the JSON codec in C (HEADROOM_PLAN P2),
│ │ byte-identical to ringlib/json.ring
│ ├── serve.zig embedded dev HTTP server (correct wasm MIME)
│ └── ringlib/ pure Ring baked into the wasm
│ ├── json.ring the pure-Ring JSON codec — the REFERENCE
│ │ the C codec is held byte-identical to
│ │ (and what native Ring runs)
│ ├── json_wasm.ring the wasm JSON surface, over src/rs_json.c
│ ├── seam.ring Page() and Platform(), the outward seam
│ ├── stzZql.ring the ZQL engine — see docs/zql-payload.md
│ └── stzzql_smoke.ring its test suite
│
├── ringvm/ vendored Ring VM (src + include) + 4 patches
│
├── playground/
│ ├── index.html the Playground (the site's single page)
│ ├── examples/ the 24 examples, one plain .ring file each
│ ├── examples-data.js their manifest (id / title / give answers)
│ ├── site.css shared design tokens
│ ├── soak.html endurance, in a real browser on a real device
│ ├── stress.html the tontine ledger, as a page you can run
│ ├── stress.ring its Ring source — the whole computation
│ ├── ringscript.js the loader + WASI shim (the whole JS side)
│ └── ringscript.wasm built runtime — committed (zig build refreshes)
│
├── tests/ verification — see §6
│ ├── gates.js 66 permanent gates
│ ├── soak.js long-session endurance (what accumulates?)
│ ├── fuzz.js hostile input (can the loader be made to throw?)
│ ├── wasi.js the hand-written WASI shim, against the host itself
│ ├── boot.js the page path, over a fake document and network
│ ├── rivals/ Lua + QuickJS through the same scenarios (see rivals.md)
│ ├── bench.js speed and size vs a recorded, calibrated baseline
│ ├── stress-app.js a real application under load, oracle-checked
│ ├── orders-app.js the local-first sample application's rules
│ ├── bench-baseline.json what a regression is measured from
│ ├── examples-oracle.js Playground examples vs native ring
│ ├── samples-sweep.js bulk corpus sweep vs native ring
│ ├── extract-doc-snippets.js builds the doc corpus from your Ring install
│ └── ring-exe.js locates the native oracle on any platform
│
└── docs/ you are here
zig build # build the wasm (ReleaseSmall by default, ~350 KB)
zig build serve # ...and serve playground/ at http://localhost:8377/
zig build dist # cross-compile the server for all shipped platforms
zig build -Ddebug # debug build of the wasm, when you need one
zig build -Dmax-heap=64 # a 64 MB-capped wasm for the endurance pageRelease is the default on purpose: playground/ringscript.wasm and
bin/ringscript-serve-* are committed release artifacts (RingPM
downloads them as-is, with no build step on the user's machine), so an
ordinary build must never leave a debug binary in their place. Refresh
both — zig build && zig build dist — when bumping the version.
Committing built files is a decision, not an oversight, and it was
challenged on 2026-08-17. It stands, on a fact that is local to this
repository: package.ring ships one binary per platform and lib.ring
resolves it at runtime, so a RingPM user — who has Ring but not Zig —
needs them to exist. Deleting them would break nothing to install, in
files. The cost is 10 blobs across all history, 3.3 MB raw and 1.6 MB
packed, in a repository that also vendors the Ring VM.
The real objection was never size. It is that a built file goes stale
silently while the documentation describes what it used to do — and
that happened: bin/ was last built at 0cf5ad6, before six CLI verbs
existed, and .github/workflows/pages.yml copies bin/* into the
starter kit people download. Stale binaries were shipping. Nothing could
have noticed, because pages.yml does not trigger on src/** and no
check compared the binaries to their sources.
So zig build dist now writes bin/SOURCES.sha256 — the hash of
src/cli.zig, src/serve.zig and build.zig, carriage returns stripped
so it means the same on Windows and in CI — and
dist-current.yml recomputes it
on every push touching those files. A hash rather than a rebuild-and-diff,
because two Zig versions do not emit byte-identical output and a gate that
cries wolf is worse than none.
The generalisable shape, worth checking anywhere a repository ships a generated file: is there a freshness gate between the source and the thing that reaches the user? Here there was not, for months.
Zig is the only dependency — this repository builds with 0.15.2
(get it from https://ziglang.org/download/, or your package manager:
winget install zig.zig, brew install zig, snap install zig --classic --beta, pacman -S zig, scoop install zig; check with
zig version). There is no build.zig.zon, so nothing is fetched at
build time; the dev server (src/serve.zig) is part of the build — no
Node, no Python, no CMake. Iterating: edit src/bridge.zig or a page in
playground/, re-run zig build serve, refresh the browser.
Node.js and a native Ring install are needed only for the test suites in §6, never to build or run the runtime.
- Drop
mylib.ringintosrc/ringlib/. - Add one entry to
embedded_filesinsrc/bridge.zig:.{ .name = "ringlib/mylib.ring", .data = @embedFile("ringlib/mylib.ring") }, zig build serve— nowload "ringlib/mylib.ring"works in any page.
Keep embedded libraries pure Ring (no file/OS calls) — that's the contract that makes them portable into the browser unchanged.
Replace ringvm/src + ringvm/include with the new version's, re-apply
the live patches from VENDOR_PATCHES.md, rebuild, and
run the full test battery (§6) — the gates fail loudly if a line-number
patch is missing, and the sweep is the real gate. The 1.25 → 1.26 → 1.27
upgrades each took minutes.
Diff before you re-apply. The 2026-08-16 swap to Ring master found
three of the seven local patches already upstream — one of them, the sort
quadratic, taken verbatim. Re-applying them blind would have carried three
patches nobody needed, and would have hidden the fact that upstreaming had
worked. VENDOR_PATCHES.md has the five-step recipe.
Every claim in these docs is executable:
node tests/gates.js # 66 permanent gates: residency, errors,
# memory, io, bridge, reentrancy, json
node tests/examples-oracle.js # playground/examples/*.ring vs native ring.exe
node tests/soak.js # 40,000 evaluations: nothing may accumulate
node tests/fuzz.js # 4,000 hostile inputs: eval must never throw
node tests/wasi.js # the WASI shim: clocks, encoding, output ordering
node tests/boot.js # boot(): fetching, ordering, failures, early clicks
# and for context rather than gating — RingScript vs Lua and QuickJS:
# cd tests/rivals && npm install && node run.js (docs/rivals.md)
node tests/bench.js # speed and size vs the recorded baseline
node tests/stress-app.js # 50,000-record ledger vs a JavaScript oracle
node tests/orders-app.js # the local-first sample: pricing, credit,
# outbox, per-order sync, restart
node tests/samples-sweep.js # ~284 official Ring samples vs native
node tests/extract-doc-snippets.js && \
node tests/samples-sweep.js --root=tests/doc-snippets --dirs=.
# ~550 Ring-documentation examples vs nativeThe site has a measurement of its own. tests/site-audit.html loads every
page of site/ at a chosen viewport and checks each rendered text node's
computed size and its true contrast against the background actually painted
behind it, plus any block that scrolls sideways with page width to spare and
any menu that wrapped by accident. It is a port of the measurement half of
StzZui's UI audit, and it enforces that
project's interface rules 105, 107, 114 and 27 — the reading floor is
16 px at 4.5:1, and nothing on the site is below it. Serve the
repository root (python -m http.server 8399) and open
localhost:8399/tests/site-audit.html; check 375, 768, 1023, 1024 and 1440.
Current status: clean at every width, 15 pages.
The oracle suites run each program through both the wasm runtime
and a native ring.exe, then compare byte-for-byte (nondeterministic
programs — clock, random, date — are required to run cleanly rather
than match). Current status: zero mismatches, zero failures.
Every suite above runs in Node, on a machine with a great deal of memory.
playground/soak.html runs the same two soak workloads in a browser —
open it on the phone or tablet you actually care about, press Run, and
read the verdicts. It yields between batches so the tab stays
responsive, and the tables scroll inside their own box rather than
pushing the page sideways.
zig build -Dmax-heap=64 additionally emits ringscript-capped.wasm, a
64 MB-ceilinged build (same source, same flags — only the ceiling
differs; the shipped artifact is never touched and the capped one is not
committed). Picking it in the page adds a third phase that a desktop
cannot show you: what happens when the browser refuses to grow the
heap, rather than merely taking its time.
The answer, measured: at the ceiling the Ring VM calls proc_exit(1)
rather than returning an allocation failure — Ring's allocator exits on
out-of-memory, as it does natively. The WASI shim turns that into a
thrown error, the loader turns that into an ordinary
{ ok: false, error }, and the page survives. The VM itself does
not: it needs ring.reset(), which the error message says, and a fresh
instance loads even under that pressure. So the promise that holds on a
constrained device is "an error never takes the page down", not "the VM
shrugs off exhaustion".
Browser numbers agree with Node's: 40,000 evaluations, phase 1 dead flat at 20.8 MB with zero classes accumulated, phase 2 bounded.
examples-oracle.js reads its programs straight out of
playground/examples/ — the very files the Playground fetches — so what
is verified is what a reader is shown, not a copy of it. Adding an
example means adding the .ring file and one manifest line in
playground/examples-data.js; the oracle then covers it automatically,
and the file stays runnable with ring.exe on its own.
Recorded baselines, not aspirations: tests/bench.js measures these on
every run and fails if one regresses beyond 40%. Taken on an Intel Core
5 210H, Node 22, ringscript.wasm at 396,030 bytes.
| min | what it exercises | |
|---|---|---|
instantiate + rs_init |
3.2 ms | a fresh instance (module cached, memory stamped from a post-init snapshot — HEADROOM_PLAN P1) |
? 1+1 |
0.043 ms | one full eval round trip |
| 10,000-iteration loop | 0.688 ms | VM dispatch (computed goto + -O2 core) |
| build a 2,000-char string | 0.215 ms | string growth |
| sort a 2,000-element list | 0.202 ms | library call |
| create 2,000 objects | 3.06 ms | allocation (template cache — HEADROOM P5) |
| 1,000 lines of output | 0.764 ms | the see hook |
ring.call from JS |
0.053 ms | the bridge, JSON both ways |
| parse a ZQL declaration | 1.40 ms | the shipped payload |
| JSON encode 8.7 KB | 0.13 ms | the C codec (src/rs_json.c) |
| JSON decode 8.7 KB | 0.66 ms | ...held byte-identical to ringlib/json.ring |
Two things are worth reading off that table. Startup at 3.2 ms is the
figure that matters most for a page, and it is comfortable — the first
instance pays a one-time wasm compile on top; every later one is
stamped from a snapshot. JSON is no
longer slow at all: since HEADROOM_PLAN P2 the codec is C
(src/rs_json.c), held byte-identical to the pure-Ring reference
(ringlib/json.ring, which native Ring still runs) by a permanent
differential gate. A 1 MB value through ring.call took 260 seconds
when this story began, ~1 s after the pure codec went linear, and
6 ms now — on the rivals board Ring wins JSON encode outright,
ahead of QuickJS's native codec.
Four details make the baseline honest rather than decorative:
- It is calibrated. Milliseconds mean nothing on someone else's machine, so each run also times a fixed JS workload — integer arithmetic plus a strided walk over 4 MB, because interpreting Ring chases pointers rather than staying in registers — and compares ratios.
- It reports the minimum. Timing noise is always additive; a sample is never faster than the truth. The median is printed beside it to show the spread.
- Anything over the line is measured again before it is called a regression. Run-to-run noise here is ±8%, far inside the tolerance, but a background process landing on the wrong core can inflate a single benchmark — and a suite that cries wolf gets ignored, which protects nothing.
- Size is gated harder than speed (2%, and it needs no calibration). This project has twice chosen the smaller binary over the faster one — ReleaseSmall over ReleaseFast, the Ring ZQL over the Zig one — so a harness watching only speed would quietly reward the trade it has already rejected.
Verified by measuring a debug build against the baseline: all eleven benchmarks fail on both the first and the second look (+68% to +310%), as does the size check (+631%). And the release build is reproducible — rebuilding after a debug build returns the committed artifact byte-for-byte.
Re-record with node tests/bench.js --update, and say in the commit
message why the numbers moved.