A native-JIT embedded scripting language. Ember compiles scripts to x86-64
machine code at runtime — no interpreter dispatch loop, no bytecode VM. A hot
loop runs as real native instructions, not a switch-on-opcode interpreter, so
it posts 6-7x faster than AngelScript's bytecode interpreter on compute-
heavy workloads (fib, tight_loop, nested_calls) and within 1.04x of
g++-O2 on integer division (the one path where ember's tree-walker codegen
is already near-optimal). Around that core it ships a real sandbox (per-frame
byte budget, stack-depth guard, recoverable traps — a fault unwinds to your
host, it doesn't kill the process), hot-reload of live modules with stable
dispatch slots, .em module bundling (signed, native-code bundles with a
relocation contract, plus a v5 IR-on-disk variant that is re-emitted to x64 at
load time), a VST3 audio plugin wrapper (write VST plugins fully in ember
with hot-reload DSP), and a Rust-like syntax built for game-engine /
modding embedding.
The default codegen path is a baseline JIT — correctness-first, tree-
walking, stack-spilling — which is 5-8x slower than g++ -O2 on most paths
(loop_overhead 5.1x, call_overhead 5.1x, dce_dead_store 7.2x). Closing that gap
is the purpose of the IR backend + optimization passes + register allocator,
which ship behind the --passes flag (default-off, so the default path is the
unchanged baseline tree-walker). With --passes, the optimization passes
provide targeted speedups: constprop 1.34x, int_div 1.31x (closing to
within 1.30x of g++-O2). The linear-scan register allocator promotes hot
loop-carried frame slots to callee-saved registers, reducing memory traffic in
tight loops.
23 IR passes shipped (16 optimization + 7 obfuscation):
- Optimization: constprop, dce, cse, licm, forward, copyprop, instcombine, dse, simplifycfg, bounds-elim, sccp, lsr, unroll, spill_elim, peephole, branch_folding
- Obfuscation: subst (MBA), mba_expand, const_encode, opaque_pred, deadcode, str_encrypt, block_split
Full SSA construction (phi nodes, SSA renaming) remains the future upgrade; the shipped regalloc is the linear-scan-over-thin-IR subset (assigns scalar int/bool VRegs to Win64 callee-saved registers, spills to frame slots under pressure, promotes hot loop-carried frame slots to registers). Current version: v1.2.
A modular damage calculator that exercises type inference, enums, structs, for-each over slices, pattern matching, operator overloads, and native calls — with comments marking where ember's unique properties show:
// Enums are C-style named constants. The untyped form (`enum E { ... }`)
// has i32 variants auto-incrementing from 0; an explicit `= N` sets the next
// base. The typed form `enum E : T` (e.g. `enum Color : i32`) makes the enum
// name a real type backed by T — see the Language spec below. This example
// uses the untyped form; `E::A` rewrites to an i64 literal at sema time.
enum Damage { Physical, Fire = 10, Ice, Lightning }
// Structs are value types with Ember's tight-packed layout (no C++ alignment).
// Passed by value: ≤8 bytes → one register; >8 bytes → hidden pointer (Win64 ABI).
struct Enemy {
hp: i64;
weakness: i64; // holds a Damage enum value (untyped i32 → i64)
name: string; // opaque host handle — string is an extension type
}
// Type inference: `let x = expr` infers x's type from the initializer.
// No need to write `let x: i64 = 0` — ember infers it.
fn compute_damage(base: i64, type: i64) -> i64 {
// match: pattern matching with no-fallthrough arms (unlike switch).
// Each arm is a separate branch — no `break` needed.
// `_` is the wildcard/default arm.
match (type) {
Damage::Physical => { return base; },
Damage::Fire => { return base * 2; },
_ => { return base; },
}
return 0;
}
// Operator overloads: `string + string` concatenates (registered by the
// string extension via BindingBuilder::add_overload). The `+` here is
// a real native call, not syntax sugar.
fn label(e: Enemy) -> string {
return e.name + string_from_i64(e.hp);
}
fn main() -> i64 {
// Array literal — a fixed-size i64[3] allocated on the frame.
let hps: i64[3] = [100, 200, 50];
// View: `arr[..]` creates a slice {ptr, len} pointing at the array's
// backing storage. No copy — the slice is a two-word {pointer, length}.
let slice: i64[] = hps[..];
// for-each: iterates a slice, binding each element to `hp`.
// Desugars to a while loop with index + bounds check.
// The element type is inferred from the slice's element type.
let mut total: i64 = 0;
for (hp in slice) {
// `as` is the explicit cast operator. Implicit narrowing is rejected
// by sema (i64 → i32 requires `as i32`; `let y: i8 = x_i64` is an error).
let d: i64 = compute_damage(hp as i64, Damage::Fire);
total = total + d;
}
// String construction via the string extension's native functions.
let goblin: Enemy = Enemy { hp: 150, weakness: Damage::Fire, name: string_from_i64(0) };
let tagged: string = label(goblin); // operator overload: string + string
let len: i64 = string_length(tagged); // opaque handle → i64
// The i64 return becomes the process exit code. The source `run`/`pipe`
// paths mask the i64 with `& 0x7fffffff` (clearing bit 31) first, then the
// OS truncates to 8 bits (>255 wraps); `--load-em` uses `int(result)` (no
// 31-bit mask). Either way the observable exit code is the low byte.
return total + len;
}What this example shows:
- Type inference (
let total = 0,let d = compute_damage(...)) — no redundant type annotations - Enums — untyped (
enum E { ... }, i32 variants) + typed (enum E : T); auto-increment + explicit values (Damage::Fire = 10,Damage::Ice = 11); variant values may be aconstexpr fncall - Structs as value types with field access (
e.hp,e.name) - for-each over a slice (
for (hp in slice)) — no manual index loop - match with no-fallthrough arms + wildcard (
_ => default) - Operator overloads (
string + string→ concatenation via a registered native) - Explicit casts (
hp as i64) — no implicit narrowing - Opaque host handles (
string— an i64 backed by a host-sidestd::string) - Slice views (
arr[..]— zero-copy {ptr, len} pair) - Native calls (
string_from_i64,string_length,sqrt— registered viaBindingBuilder)
All of this compiles to native x86-64 machine code at runtime — no interpreter, no bytecode.
C++17, no external deps beyond Windows.h. Builds clean on MinGW g++ 15.2.0 +
Ninja (the supported path). MSVC x64 is unsupported — CMake fails loudly
because no working MSVC B1 thunk exists.
cd ember
mkdir buildt && cd buildt
cmake -G Ninja -DCMAKE_CXX_COMPILER=/c/msys64/mingw64/bin/g++.exe ..
cmake --build . # or: ninja
ctest # 70 tests (67 excluding benchmarks + soak)Run and bench a script (ember = buildt/ember_cli.exe):
ember run hello.ember # compiles + calls main(); exit code = low byte of (i64 & 0x7fffffff)
ember bench hello.ember # microbenchmark: warmup + N timed iters, prints min/median/mean/p99/stddev
ember test tests/lang # native test runner: classify + compile/run every .ember in a directoryCLI reference:
ember run <file.ember> [--fn NAME] [--dump] [--emit-em OUT.em] [--passes P1,P2,...] [--tick [--tick-count N] [--tick-interval MS]]
ember emit-em <file.ember> <out.em> # precompile without running
ember bench <file.ember> [--fn NAME] [--iters N] [--warmup N]
ember test [dir] # run every .ember file in <dir> (default tests/lang/)
ember run --load-em <file.em> [--fn NAME]
ember bundle <file.ember> <output.exe> [--stub <stub.exe>] [--fn NAME] [--permissions none|ffi] [--output-permissions stub|preserve] # bundle into standalone exe
ember pipe <config> # dataflow pipeline runner (Family C): load N .em modules, wire a stage graph, stream i64s through it
ember live <file.ember> [--tick ...] # live-coding/reload runner: recompile on file change, show tick output evolve
runcompiles and calls the entry function (defaultmain). Ani64return becomes the process exit code: the sourcerun(andpipe) paths first mask the result with& 0x7fffffff(clearing bit 31), then the OS truncates to the low 8 bits (>255 wraps — OS truncation, not a C++%);voidexits 0. The--load-empath instead usesint(result)(no 31-bit mask) before the same OS 8-bit truncation, so the two paths agree on the low byte but differ on whether bit 31 is cleared first.--fnoverrides the entry.--dumpprints each compiled fn's slot, byte size, and reloc count.--emit-emprecompiles to a.embundle.--ffi/--allow-iogrant FFI permission: enable the I/O natives (print,println,print_i64,print_f64, file/path natives). Without it, an I/O native call is a compile-time sema error.--allow-iois an alias.--gc-envallocates lambda closure environments on the tracing-GC heap instead of stack-frame temporaries (off by default); needed for a by-value lambda that escapes its defining scope without a use-after-scope.--tickruns@on_tickfns + anyregister_routine-registered routines on a tick thread (its owncontext_t, isolated from the main thread) at--tick-intervalms (default 16);--tick-count Nauto-stops after N ticks.ember_check <file>(parse-only) andsema_check <file>(parse+sema) are the one-shot "does this parse/sema-check?" tools the lang regression suite drives.
import "path"; is textual inclusion before lexing (cycle-detected, deduped),
so multi-file scripts work. The CLI run --load-em <file.em> action loads a
pre-compiled .em bundle (see the CLI reference above); the host
load_em_file/link_em_file API and the link "mod.em" as m; source
directive are the embedding-side equivalents. A v1 .em carries embedded
process-local pointers, so it is ABI/process-trusted, not a portable
interchange format.
ember is built for embedding — you use it as a scripting layer inside a host application (game engine, audio plugin, tool, pipeline). Pick your use case and follow the quick start:
Write game logic in ember, hot-reload it while the game runs:
# Write a script with @on_tick
ember live examples/scripts/game_logic.ember --tick --tick-interval 16
# Edit the .ember file → ember recompiles on save, no restart needed- Hot reload:
docs/HOT_RELOAD.md— dispatch slots, epoch/quiescence reclamation - Lifecycle:
docs/LIFECYCLE.md—@entry,@on_tick,@event(...)annotations - Example:
examples/scripts/game_logic.ember— modular damage calculator - Host embedding:
examples/game_host.cpp— how to embed ember in a C++ host
Write VST3 plugins fully in ember — the C++ wrapper handles the VST3 API, you write the DSP:
# Build the VST3 wrapper plugin
cmake --build buildt -j 8
# Output: buildt/VST3/Release/ember_gain.vst3/
# Write your DSP in ember (see examples/vst3_wrapper/gain_vst.ember)
# @realtime annotation validates real-time safety at compile time- Plan:
docs/planning/plan_VST3_EMBER_WRAPPER.md— full architecture - Example plugin:
examples/vst3_wrapper/gain_vst.ember— stereo gain - DSP harness:
examples/vst_dsp_harness.cpp— headless DSP testing - Audio natives:
extensions/audio/—load_f32/store_f32/audio_* - @realtime checker: rejects GC/alloc/IO/threads/exceptions in RT functions
Chain ember modules into a pipeline that processes a stream of values:
# Run a 3-stage pipeline: A::process -> B::reduce -> C::square
ember pipe tests/features/pipe_3stage.pipe- Pipeline config:
.pipetext format —module <alias> <path>,stage <alias>::<fn>,input <start> <count> - Examples:
tests/features/pipe_2stage.pipe,pipe_3stage.pipe - Modules:
docs/MODULES.md—.embundling + cross-module linking
Run a script with @on_tick functions, recompile on file change:
ember live examples/scripts/control.ember --tick --tick-count 10 --tick-interval 100
# Edit control.ember → ember recompiles, prints "reloaded (epoch N)"- Hot reload mechanics:
src/hot_reload.hpp—HotReloadDomain,ExecutionGuard - Example:
tests/features/live_tick.ember
Bundle a .ember script + the ember runtime into a single .exe:
# Bundle a script into a standalone executable
ember bundle my_script.ember my_app.exe
# Or use the dedicated bundler:
ember_bundle my_script.ember my_app.exe
# Run it anywhere — no ember install needed
./my_app.exe- Bundling:
docs/BUNDLING_AND_EM_MODULES.md— the.emformat, relocations - Stub:
examples/ember_stub_main.cpp— the runtime stub - Bundler:
examples/ember_bundle.cpp— the bundler tool - Release packaging:
scripts/package_release.sh
The ember compiler (lexer, parser, sema, codegen) is being ported to ember itself:
# Run the self-hosted end-to-end pipeline
ember run self_hosted/full_pipeline.ember --fn run_demo --ffi
# Compiles + executes `fn main() -> i64 { return 1 + 2 * 3; }` → 7- Stages:
self_hosted/lex.ember,parse.ember,sema.ember,codegen.ember - Compiler entry:
self_hosted/emberc.ember— the self-hosted compiler - Tests:
self_hosted/*_test.ember(wired into ctest) - Status: subset of ember supported (i64/bool/void, let, if/while/for, arithmetic, calls)
Write your own IR passes — optimization or obfuscation:
# Run built-in passes
ember run my_script.ember --passes constprop,forward,copyprop,instcombine,dce,licm,dse,simplifycfg,bounds-elim,sccp
# Obfuscation passes
ember run my_script.ember --passes opaque_pred,deadcode,mba_expand,const_encode,str_encrypt,block_split23 passes shipped (16 optimization + 7 obfuscation):
- Optimization: ConstProp, DCE, CSE, LICM, Forward, CopyProp, InstCombine, DSE, SimplifyCFG, bounds-elim, SCCP, LSR, Unroll, SpillElim, Peephole, BranchFolding
- Obfuscation: SubstitutionPass (MBA), mba_expand, const_encode, opaque_pred, deadcode, str_encrypt, block_split
String encryption (str_encrypt) and block splitting (block_split) are
shipped obfuscation passes: string encryption transforms ConstStringRef IR
ops into StringDecrypt (XOR-decrypt at runtime), matching ember's built-in
string_xor_key feature but as a composable pass; block splitting
deterministically splits long basic blocks. Both are value-preserving (pinned
by tests/lang/valid_obf_str_encrypt.ember and valid_obf_block_split.ember,
run with --passes str_encrypt / --passes block_split).
- Pass system:
docs/spec/PASS_SYSTEM_DESIGN.md— architecture, registration, lifecycle - Pass authoring guide:
docs/PASS_AUTHORING.md— how to write a custom pass - Custom pass examples:
examples/custom_pass/— minimal_pass, nop_injection, block_merge + README - Optimization research:
docs/planning/plan_OPTIMIZATION_PASSES.md - Obfuscation research:
docs/planning/plan_OBFUSCATION_PASSES.md
Spec (docs/spec/)
TYPE_SYSTEM.md— primitives, struct layout, slices, the no-raw-pointer rule, conversion matrixCOMPILER_PIPELINE.md— lexer/BNF/AST, sema passes, the shipped tree-walking lowering, deferred SSA-liteCODEGEN_SPEC.md— the x86-64 backend: calling convention, encodings, regalloc, trapsSAFETY_AND_SANDBOX.md— threat model, budgets, depth guard, bounds checks,PERM_FFI, trap surface, thread-safetyBINDING_API.md— the shippedBindingBuilder/NativeSigAPI, Win64 slot mapping, slice conventionMEMORY_AND_GC.md— ownership taxonomy, slice-escape check, globals, the v2-GC deferral rationaleBENCHMARK_SYSTEM_DESIGN.md— the bench harness design, the g++ -O2 axis, methodologyPASS_SYSTEM_DESIGN.md— the composable pass architecture: extension-style discovery + LLVM-style pass interface, the shipped opt/obf passesCODEGEN_OPTIMIZATION_DESIGN.md— the codegen optimization design: per-path waste mapping, staged SSA-lite IR plan, pass interface
Usage
docs/BUNDLING_AND_EM_MODULES.md— the.embinary bundling format:EMBLcontainer, relocations, writer/loaderdocs/MODULES.md— the module model:.em→ dispatch table + globals, slot stability across reloadsdocs/HOT_RELOAD.md— dispatch table, slot stability, reload protocol, epoch/quiescence reclamationdocs/LIFECYCLE.md—@entry/@on_tick/@event(...)annotation discovery +register_routine
Planning
docs/ROADMAP.md— every v2+ deferral with a re-entry trigger + dependency, tiered by build order; hard non-goalsdocs/planning/DESIGN.md— the index doc: top-level plan, architecture diagram, milestone listdocs/planning/GAP_ANALYSIS.md— the completeness audit: original-request requirements → where satisfied
Type system. Primitives i8/i16/i32/i64, u8/u16/u32/u64, f32/f64,
bool, string (owned, via the string extension), and slice[T] (a bounded
view — the no-raw-pointer rule). User types: struct (value type, field layout
queryable via sizeof/offsetof) and enum — untyped (enum E { ... },
i32 variants, E::A rewrites to an i64 literal at sema) or typed
(enum E : T, e.g. enum Color : i32, makes the enum name a real type backed
by T; enum→int implicit widening is allowed, int→enum is rejected). First-class fn handles (&fn / handle(args))
backed by the dispatch table.
Syntax (Rust-like).
- Bindings:
let x: i64 = 0;,let mut x = 0;,const N: u64 = sizeof(i64); - Functions:
fn name(p: i64) -> i64 { ... }; default+trailing-optional params;constexpr fn name(...) -> i64 { ... }— a fn that can be const-evaluated at sema time when called with all-constant args (the call is rewritten to anIntLit; bounded interpreter, i64 integer fns only, falls back to a runtime call for non-constant args or float/bool/struct fns) - Compile-time assertions:
static_assert(cond, "msg");— foldscondat sema (cond may be a literal expr or aconstexpr fncall); a false result is a compile error withmsg, a true result is elided (no runtime code) - Control:
if/else,while,for (init; cond; step),for (x in slice)(andfor (x in array_handle)via theiterable()hook),do { } while(cond);,switch/case/default/break,match (expr) { pat => body, _ => default },continue,return defer— cleanup runs at lexical-block exit (LIFO), including onbreak/continue/returnedges- Operators:
+ - * / %, comparisons,&& ||,ascasts,++/--,+=etc. - F-string lowering through the string extension
Safety model. W^X JIT memory; a per-frame byte budget (instructions), a
stack-depth guard, and bounds checks. Every fault — budget exhaustion, stack
overflow, div-by-zero, INT64_MIN/-1, OOB — funnels through one non-local
unwind to the host: a trap is recoverable, never process death. PERM_FFI
gates native calls. v1.0 adds call-target-provenance (the REDSHELL #6 guard) and
context thread-safety (per-thread context_t, one compiled body serves N
contexts). Documented gaps are listed in SAFETY_AND_SANDBOX.md.
Modules / .em. link "mod.em" as m; resolves a compiled bundle to a
dispatch table + globals block; m::fn() cross-module calls go through the real
grammar. .em bundles are signed, carry relocations (DispatchTableBase /
GlobalsBase), and keep slot stability across hot reloads. A v1 .em is
ABI/process-trusted (process-local pointers), not a portable format —
cross-process portability is a versioned-relocation v2+ item.
Binding API. Register natives with BindingBuilder + NativeSig; the
host maps script types to Win64 slots and ships a slice convention. The fifteen
NativeSig addon extensions (vec/quat/mat/string/array/math/map/
sync/thread/coroutine/lifecycle/io/call_raw/audio/gc) register their
NativeSig + OpOverloadTable entries the same way. (The standalone ember CLI
links and registers all fifteen — see extensions/README.md.) Two pass extensions
(opt, obf) are a separate category — they register IR→IR transforms via
register_passes (not register_natives); see
docs/spec/PASS_SYSTEM_DESIGN.md and docs/PASS_AUTHORING.md. The fluent TypeBuilder/StructBuilder
surface stays trigger-gated on a host needing script-visible C++ struct types.
ember is dual-licensed:
-
AGPL-3.0 for open-source and community use — if you use ember in a project that you distribute or make available as a network service, you must open-source your integration under AGPL-3.0. See
LICENSE. -
Commercial license for proprietary/commercial use — if you want to use ember without the AGPL copyleft obligation (e.g., in a proprietary product or commercial service), you need a commercial license. See
COMMERCIAL_LICENSE.mdor contact connor.postma@gmail.com.
This dual-license model ensures ember stays free for open-source development while allowing sustainable commercial integration. External contributions require a CLA (Contributor License Agreement) to preserve the ability to offer commercial licenses.
