
Odin context in freestanding_wasm32
Odin has this context feature, but using it with WASM (non-WASI) can add unwanted overhead.
I made some findings in how to avoid the overhead.
This guide is written with a focus on Firefly Zero, but also applies to other low-powered WASM environments such as WASM-4 or Gamercade (as fantasy consoles goes), or even when writing WASM plugins.
For a “tl;dr”, jump down to ## The solution part below.
Context
As a fan of WASM, Firefly Zero, and of “hipster languages” (like MoonBit, Crystal, CUE), then this intersection of “Firefly × Odin” really sparks my joy.
Firefly Zero has a firefly-odin SDK
but they intend the developer to use Odin’s contextless calling convention
everywhere. But that irks me, as I want to use Odin in the “normal way”
(dare I say: “idiomatic way”?).
// what i don't want to use
my_func_1 :: proc "contextless" () {
// ...
}
// I want "odin" calling convention
my_func_2 :: proc () {
// ...
}
Adding "contextless" everywhere isn’t the end of the world. But the Odin
standard library uses "odin" (default) everywhere.
The problem
You can opt-in to using context. For example:
my_func_setup_context :: proc "contextless" () {
context = runtime.default_context()
my_func_with_context()
}
my_func_with_context :: proc () {
// Allocate 128 byte array using allocator from context
stuff := make([]byte, 128)
defer delete(stuff)
}
But for me this didn’t even work, as Odin doesn’t seem to provide a
default allocator in freestanding_wasm32. So my_func_setup_context must
instead be:
my_func_setup_context :: proc "contextless" () {
context = runtime.default_context()
when (ODIN_ARCH == .wasm32) {
default_context.allocator = runtime.default_wasm_allocator()
}
my_func_with_context()
}
The real problem however is the fact that runtime.default_context()
sets up a brand new context each time.
We could in theory do this:
default_context: runtime.Context
init_default_context :: proc "contextless" () {
default_context = runtime.default_context()
when (ODIN_ARCH == .wasm32) {
context = default_context
default_context.allocator = runtime.default_wasm_allocator()
}
}
@(export)
boot :: proc "contextless" () {
init_default_context()
context = default_context
// ...
}
@(export)
update :: proc "contextless" () {
context = default_context
// ...
}
@(export)
render :: proc "contextless" () {
context = default_context
// ...
}
(boot, update, and render are Firefly Zero callback)
But when analyzing the compiled output (e.g via wabt’s wasm2wat
or binaryen’s wasm-dis),
then we see that Odin is still calling runtime::_core.odin_::__init_context
in boot and render, which is where the overhead is coming from.
We want to just blindly reuse the global default_context.
(WASM is single-threaded, so we don’t have to worry about race conditions)
In Firefly Zero, the Firefly runtime calls a set of functions rapidly, up to
60 times per second. Shaving off this context setup on each
update and render can actually save a substantial amount of processing,
especially in Firefly Zero’s case where it’s running on a WASM interpretor
on a low-powered ESP32 chip.
Now to be real, the amount of overhead is still minimal.
Using Firefly’s ff runtime monitor, the overhead is only around ~150 fuel
(an approximation of processing cost provided by WASMI).
That’s ~150 WASM instructions. Which is really low.
But when I see a problem, and a path to solution, then I cannot rest until I find a solution.
The inspiration
Taking inspiration from how Odin deals with this when targeting WASM+JavaScript
(ODIN_OS.JS), they set up the context only once, get a pointer to it, and then
from JavaScript they follow the odin calling convention by passing that
pointer to every Odin procedure in WASM. For example:
// excerpt of JavaScript glue code
// "exports" is the "WebAssembly.instantiate(...).instance.exports"
if (exports.step) {
const odin_ctx = exports.default_context_ptr();
function step(currTimeStamp) {
// ...
const dt = (currTimeStamp - prevTimeStamp) * 0.001;
if (!exports.step(dt, odin_ctx)) {
// ...
}
}
}
Source: core/sys/wasm/js/odin.js
The exported step procedure would then be defined in Odin like so:
// no "contextless" thanks to JavaScript passing "odin_ctx"
@(export)
step :: proc(delta_time: f64) -> bool {
// ...
}
The default_context_ptr is defined in Odin’s base/runtime/procs_js.odin.
Together with where the init_default_context sets up the default context to
be reused, and runs on @(init).
The solution
I couldn’t get @(init) to work on freestanding_wasm32 target. So we will
need to trigger that function in another way.
I’ve defined the following in my Odin package:
default_context: runtime.Context
@(export)
init_default_context :: proc "contextless" () {
default_context = runtime.default_context()
when (ODIN_ARCH == .wasm32) {
context = default_context
default_context.allocator = runtime.default_wasm_allocator()
}
}
@(export)
default_context_ptr :: proc "contextless" () -> ^runtime.Context {
return &default_context
}
Now to trigger it, as explained above, using context = default_context_ptr()
doesn’t compile to the correct result.
So we’ll write our own glue code. Since the logic is so simple, then we can
even write it in WebAssembly text format (.wat):
(module $init
(import "app" "init_default_context" (func $app.init_default_context))
(import "app" "default_context_ptr" (func $app.default_context_ptr (result i32)))
(import "app" "boot_context" (func $app.boot_context (param i32)))
(import "app" "render_context" (func $app.render_context (param i32)))
(func (export "boot")
(call $app.init_default_context)
(call $app.boot_context
(call $app.default_context_ptr)))
(func (export "render")
(call $app.render_context
(call $app.default_context_ptr))))
We are importing init_default_context and default_context_ptr from the
Odin app code. We are also importing boot_context and render_context as
the boot and render equivalent Firefly Zero callbacks,
but using the "odin" calling convention. If we want more callbacks
(or other callbacks in case you’re using something else), then you need to
remember to add them to this WAT file.
So we need to add the following to our Odin script:
@(export)
boot_context :: proc() {
// boot with context :D
}
@(export)
render_context :: proc() {
// render with context :D
}
The Odin script must be built into WASM, which can be done with:
odin build . -target:freestanding_wasm32 -out:my-app.wasm
We then merge these two WASM modules together (one from Odin, one from our own WAT script) using binaryen’s wasm-merge.
In its simplest form, you just give it the module paths:
wasm-merge <module-1-path> <module-1-name> <module-2-path> <module-2-name> -o build/app.wasm
(wasm-merge supports reading from a mix of .wat and .wasm files)
The module-*-name parameters defines the “WASM module name” used when
cross-referencing imports. So given our .wat script above, then we want to
name our Odin WASM module as app.
In my case, with Firefly Zero, I also need to enable a bunch of WASM features.
So my final wasm-merge becomes:
wasm-merge ./my-script.wat init build/my-app.wasm app \
-o build/merged.wasm \
--disable-exception-handling \
--disable-gc \
--enable-bulk-memory \
--enable-extended-const \
--enable-memory64 \
--enable-multivalue \
--enable-mutable-globals \
--enable-nontrapping-float-to-int \
--enable-reference-types \
--enable-relaxed-simd \
--enable-sign-ext \
--enable-tail-call \
--debuginfo
The final step would be to also run a final wasm-opt:
wasm-opt build/merged.wasm \
-o build/merged-optimized.wasm \
-Os \
--dae-optimizing \
--disable-exception-handling \
--disable-gc \
--enable-bulk-memory \
--enable-extended-const \
--enable-memory64 \
--enable-multivalue \
--enable-mutable-globals \
--enable-nontrapping-float-to-int \
--enable-reference-types \
--enable-relaxed-simd \
--enable-sign-ext \
--enable-tail-call \
--strip-debug \
--strip-dwarf \
--strip-producers
And that’s it :)
Bonus
Best is to toss these command-lines into something like a Makefile, a justfile,
a Taskfile.yml, or a mise.toml.
Personally I prefer mise.toml, as with mise-en-place
you can also define all the dependencies you need, such as the binaryen ones:
[tools]
odin = "dev-2026-09"
# for wasm-merge, wasm-opt, and wasm-dis
"aqua:WebAssembly/binaryen" = "latest"
# needed for Odin to compile to WASM
"github:WebAssembly/wasi-sdk" = "latest"
# Firefly Zero tooling
"github:firefly-zero/firefly-cli" = { version = "0.19.0", rename_exe = "ff" }
"github:firefly-zero/firefly-emulator" = "0.13.0"
[env]
# this tells Odin where to find "WebAssembly/wasi-sdk"
WASI_SDK_PATH = { default = """{{ tools["github:WebAssembly/wasi-sdk"].path }}""", tools = true }