Opening Book Guest module ABI

Write a module.

A repertoire only grows if other people can add to it. This is the whole contract: two required exports, one calling convention, and a signed artifact.

01
A move is code, shipped without shipping a client.

Why a module at all

A new opening move usually means a new client release. Release cycles are measured in weeks; a censor's response is measured in days. That asymmetry is the whole problem.

So the moves live outside the binary. A module is a small WebAssembly program, signed, that the client loads at runtime and runs in a sandbox with no filesystem, no network, and no clock — it sees bytes and returns bytes. Adding a move is publishing an artifact, not shipping an app.

The sandbox is what makes accepting outside contributions tractable. A module cannot open a socket, read a file, or learn where it is running. The worst a hostile module can do is corrupt its own connection and burn its fuel budget.

02
Two exports required. The rest declare what your module is.

The export contract

The host discovers what a module does by looking at what it exports. There is no manifest and no registration call — the exports are the declaration.

ExportSignatureMeaning
memoryrequired Your linear memory, exported so the host can write inputs into it and read outputs back out.
alloc(i32) → i32 required Hand back an address with room for len bytes. Called exactly once per invocation, before the work function.
transform_out(i32,i32) → i64 Reshape bytes on their way out. Export this and you are a byte transform.
transform_in(i32,i32) → i64 The inverse, on the way in.
compute_gambit(i32,i32) → i64 Compute an opening from the per-connection context. Export this and you are a gambit module.
handshake_step(i32,i32) → i64 Drive an interactive opening. Export this and the client runs a handshake before steady state.
init(i32,i32) → () optional One-time setup with configuration bytes.
reset() → () optional Called after every invocation once output has been copied out. Rewind your scratch arena here.

The three work roles are independent, and a module may be exactly one of them. A pure byte transform exports memory and alloc — always required — plus the two transform_* functions, and no other work-role export; the client sees no handshake_step, so it dials straight through without asking. Nothing in the wiring knows which protocol you implement.

reset is not a destructor. It rewinds the per-call scratch arena. Anything you keep in globals or at fixed addresses — keys derived during a handshake, a counter, negotiated state — survives across calls, and is exactly how a handshake module hands state to its own steady-state transforms.
03
One convention for every work function.

The calling convention

All four work functions share one calling convention, so learning it once covers everything.

  1. The host calls alloc(len) and gets back an address.
  2. It writes len input bytes into your memory at that address.
  3. It calls the work function with (ptr, len).
  4. You return one packed i64: the output address in the high 32 bits, the output length in the low 32.
  5. The host reads your output, then calls reset if you export it.
// returning a region — the only calling convention there is let out_ptr: u32 = ...; let out_len: u32 = ...; ((out_ptr as u64) << 32) | (out_len as u64)

Two roles add a little structure on top. handshake_step receives the bytes that arrived from the peer — empty on the very first call, which is your chance to speak first — and frames its reply as a status byte followed by the wire bytes to send: [status][outbound…], where a status of 1 means the handshake is finished. compute_gambit returns an encoded genome, which the client passes through without interpreting; only the engine that consumes it looks inside.

Your output must live somewhere the host can still read it. Return a region inside your exported memory that stays valid until reset. Writing to a stack address that has already been popped is the most common way a first module fails, and it fails intermittently.
05
Every limit is a trap, not a truncation.

Resource limits

A module that misbehaves is stopped, never quietly accommodated. Knowing the edges up front means you size your buffers once and stop thinking about it.

LimitValueWhy
Input / output1 MiB The largest chunk the host will hand you, and the largest it will read back. Size your arena to match and no valid call is ever rejected.
Linear memory16 MiB Fuel bounds compute, not allocation — without a ceiling a module could grow memory until the host died, cheaply.
Table size4096 The same bound for function references.
Fuel5,000,000 + 1024/byte Refilled per call, so bulk work is never penalized. It bounds a spin, not throughput.

The fuel formula is deliberately generous: a legitimate transform of a full 1 MiB chunk has over a billion units to work with. It exists so that a module with an accidental infinite loop traps instead of hanging the connection forever.

06
Eighty-three lines, no_std, no dependencies.

A worked example

The reference module XORs every byte with a constant — deliberately trivial as a transform, and the same shape a serious module has. The excerpts below are excerpts, not a compilable listing; the complete module is about eighty lines of no_std Rust with no dependencies.

// The scratch arena, sized to the host's 1 MiB maximum so no valid // call is ever rejected. `alloc` hands the host this one buffer. const ARENA: usize = 1 << 20; static mut MEM: [u8; ARENA] = [0; ARENA]; // Trap on a bad request rather than let pointer math corrupt memory. #[no_mangle] pub extern "C" fn alloc(len: i32) -> i32 { if len < 0 || len as usize > ARENA { core::arch::wasm32::unreachable() } // … return the arena's address in linear memory }

Abridged — the elided bodies, the memory export and the two transforms are in the module's source.

Three decisions in that fragment are worth copying. The arena is static, not stack, so the region you return is still valid when the host reads it. alloc traps rather than clamps on an impossible request — a truncated buffer would corrupt a connection silently, and silence is the enemy. And the module is no_std with no dependencies, which is why the compiled artifact is measured in kilobytes.

The transform itself is the boring part: read len bytes at ptr, XOR each with the key, write them back, return the packed region. Because XOR is involutive, the same code serves as both transform_out and transform_in — a real module would have two different functions here.

Start from it. The reference module is the smallest thing that exercises the full production path: it is compiled, signed, loaded, and round-tripped by the client's own test suite, through exactly the code path a shipped module takes. If your module has the same skeleton, the plumbing is already proven.
07
Unsigned is unloadable.

Signing and shipping

Runtime-loadable code is a supply chain. The client will not run a module it cannot verify, and there is no flag to turn that off.

  1. Compile to wasm32-unknown-unknown, release.
  2. Sign the module — the signature covers the name, the version, and the bytes together, so an artifact cannot be renamed, nor have its version relabelled, without breaking verification.
  3. The result is a single self-describing artifact the client loads by path or by fetch.

The build script does all three, and it is the only step in the whole project that needs the wasm toolchain — ordinary builds and CI consume the committed artifacts rather than rebuilding them.

Development artifacts are signed with a development key that ships in the repo, so you can iterate without touching anything sensitive. That key is only a fallback for builds with no production key pinned: a release pins its own verification key, the production private half never leaves its vault, and CI is public-key only. So a module signed with the repo key is loadable by a development build and by nothing you would ship — which is the property that makes shipping the dev key in the open safe.

Signing alone does not stop a downgrade. An older artifact was validly signed, so it still verifies — replaying one is a real attack, and no signature scheme prevents it by itself. The client therefore keeps an anti-rollback floor: the highest version it has installed, checked before the module is compiled, so an artifact carrying a lower version is refused even though its signature is perfectly good. What this asks of you is only that versions increase and that a number is never reused for different bytes.
08
The interesting question is what to write.

What's worth writing

The contract above is small on purpose. The hard part was never the ABI — it is knowing which opening is worth playing.

The most useful contributions tend not to be clever ciphers. They are openings that resemble something a censor cannot afford to block, transports that already have a large innocent population, and moves that fail in a boring way when they fail. A module that works beautifully until it is blocked, and then looks obviously anomalous, has made things worse than one that never worked.

If you are exploring a direction, the shape of the discussion is more valuable than a finished artifact — what population you are hiding in, what a censor would have to give up to block it, and how you would know it stopped working.