The JavaScript engine
Kog runs your compiled app on MQuickJS (Fabrice Bellard's MicroQuickJS, vendored and forked in native/vendor/mquickjs) — a single embedded JavaScript engine tuned for the ESP32. It is Kog's only engine: your app compiles to MQuickJS bytecode, which runs in place from read-only flash on the device.
Everything above the __kog host interface is the same regardless of the engine — the same compiler, the same signals runtime, the same @kog/ui and reconciler, the same bytecode-only device model. MQuickJS defines only how JavaScript is executed and where the bytecode lives in memory. There is nothing to configure: kog dev and kog deploy read the connected board's bytecode version over the dev-link handshake and confirm it matches the MQuickJS bytecode the toolchain produces.
Earlier Kog builds shipped a second engine, quickjs-ng, as the default. Kog has since unified on MQuickJS and retired quickjs-ng. A board still running the old firmware reports the retired bytecode version; kog dev / kog deploy and the browser Run on your board page detect it and ask for a one-time reflash (kog flash) to the MQuickJS runtime. KOG_JS_ENGINE survives only as a build guard — it accepts mquickjs and errors on anything else.
Why MQuickJS
The CYD (a bare ESP32 with no PSRAM, ~220 KB of DRAM) is critically RAM-starved, and it drove the engine choice. quickjs-ng — the engine Kog previously used — copies the compiled bytecode bundle into the JS heap to run it, so once an app is loaded that board has only ~3.6 KB of free heap. Add the zero-import networking prelude (spec/13), which is ~29 KB, and the JS heap simply overflows. On that class of board, quickjs-ng could render a UI but had no headroom left for networking or the hot-reload dev-link.
MQuickJS attacks exactly those costs:
- Bytecode executes in place from flash — it is never copied into the JS heap.
- The standard library lives in ROM — baked into
constC tables at build time, not built in RAM at boot. - A ~10 KB-RAM engine with a compacting GC — no libc
malloc, no reference counting, no fragmentation; it allocates only inside a caller-provided buffer.
The engine's own code is also far smaller: on a host -Os build the core is ~144 KB vs quickjs-ng's ~634 KB (~4.4× smaller). In the real firmware image that shows up as a CYD build of 1.38 MB — about 394 KB (~22%) smaller than the equivalent quickjs-ng build was.
Run in place from flash
This is the whole RAM thesis, and it is the load-bearing difference from the engine Kog replaced. quickjs-ng loaded bytecode by copying it into the JS heap; MQuickJS's JS_LoadBytecode references a flash-resident buffer directly, so the bundle never enters the heap.
The catch is that MQuickJS bytecode is base-specific: relocation bakes absolute pointers for the exact address the buffer will be mapped at, and the loader asserts the buffer sits at that base (see spec/16). Kog resolves this with a fixed-virtual-address mapping:
- Each A/B app slot is designed to map to a fixed virtual address via the ESP32 flash MMU, so a bundle relocated for that VA is bootable from either slot — preserving A/B rollback at zero runtime cost (base-specific stays base-specific; no load-time pointer fix-up). Single-slot run-in-place is what's proven on-glass today; from-either-slot A/B rollback is the
spec/16design. - The boot preludes (the signals runtime shim + the networking prelude) are firmware-baked into a dedicated read-only flash region mapped at a canonical VA (
Vp). They are relocated once forVpand loaded in place — never source-eval'd, never copied to RAM. - Relocation happens device-side, at stage time (
kog deploy), against[ROM stdlib + prelude@Vp]. Doing it on the device keeps the host bundle engine-agnostic — a single signed.kogbwith no engine-specific atom addresses baked in by the host. - Because the prelude region is provisioned to its fixed VA once and shared, boot is a single load-all-then-run-all pass: map the prelude and app in place,
JS_LoadBytecodeboth, then run prelude → app.
The payoff, measured on the working ROM-stdlib harness: the minimum JS heap needed to mount an app drops from 128 KB (source-eval) to 48 KB (run-from-bytecode), because the bytecode body stays in the flash buffer instead of the heap.
An honest, important finding: source-eval on MQuickJS actually needs more heap than quickjs-ng's source-eval did. The RAM win is entirely the bytecode-in-flash path — so on device that path is mandatory, not an optimization. The networking prelude in particular must be flash-resident bytecode; source-eval'ing it (the way the old engine ran everything) is a big part of why the CYD OOMs.
The ES5-lowering pipeline
MQuickJS is a strict-mode ES5 subset: no let/const, arrow functions, template literals, class, destructuring, spread, optional chaining, or nullish coalescing at the syntax level, and no Promise/async/Map/Set/Symbol in the library. Kog's compiler emits modern ES2020, so the build adds a lowering stage after bundling:
esbuild (iife) → tsc target ES5 → Babel elision-fill
- esbuild emits an IIFE, not an ES module — MQuickJS has no module system, so the bundle must be a self-contained script (it self-boots via a top-level
if (globalThis.__kog) mount(); there is noexportto strip). tscwithtarget: ES5, downlevelIteration: falselowers all syntax in one tool — includingasync/awaitinto TypeScript's Symbol-free__awaiter/__generatorstate machines. This is deliberately not Babel'sregenerator:regenerator-runtimepulls inSymboland per-app RAM the CYD can't spare.downlevelIteration: falsekeeps arrayfor/ofas a plain index loop (noSymbol.iterator).- A ~5-line Babel plugin fills array elisions.
tsc's__generatoremits array holes like_.trys.push([0, 3, , 4]), and MQuickJS's stricter parser rejects array elisions — so the pass replaces each hole withundefined.
The same pipeline runs everywhere Kog compiles an app: the CLI's kog dev/kog deploy, and the in-browser docs playground (which lowers with the browser twin of the CLI pass). The syntax gap therefore closes almost entirely on the compiler side, at zero runtime cost. The small genuinely-needed runtime pieces (Map, Set, Promise, and a handful of ES2015 Object/Array/String methods) are supplied by a baked JS prelude — plain ES5, zero C — that runs before your app; they are not part of the ROM stdlib (which is the C __kog/console host surface plus the engine's own builtins).
The ROM standard library
A conventional embedding builds the __kog host surface at runtime with JS_NewCFunction. MQuickJS has no such call — the entire ~60-function host surface must be declared at build time as JSPropDef tables and baked into ROM by mquickjs_build's codegen; only the C bodies are linked in. Two consequences worth knowing:
- Nested host objects can't be baked inside another baked object, so
__kog.hw/.flatlist/.netare declared as top-level ROM objects and the boot prelude assembles the tree (__kog.hw = __kogHw; …). - The 32-bit device word size matters: the ROM
JSWordtables are word-size-specific, so the host codegen runs with-m32to emit the tables the xtensa build links against.
Engine fork divergences
Per the fork charter (optimize for the ESP32; diverge freely, record everything), the vendored engine carries five deliberate in-tree changes to mquickjs.c itself. Each is diffable against the pinned upstream baseline and documented in full in native/vendor/mquickjs/PROVENANCE.md and spec/16.
- Catch-scope relaxation. MQuickJS has no block scope and rejects a duplicate
catchbinding name in one function. Minified bundles reuse short catch names (e/t/n) across siblingcatchclauses, so the parser reuses the existing local slot instead of erroring. (The binding still leaks — but our minified input never relies on a catch value persisting across sibling clauses.) - ArrayBuffer C API. MQuickJS exposes no ArrayBuffer accessors, which the networking byte-marshaling needs, so
JS_GetArrayBuffer/JS_NewArrayBufferCopywere added (moving-GC caveat: copy the pointer out before the next allocation). - ROM atom-table sizing. Each
JS_LoadBytecodepushes one ROM atom table; upstream sized the array forstdlib + one module. Kog loads several in place from flash (runtime prelude + net prelude + app), soN_ROM_ATOM_TABLES_MAXwas raised 2 → 6. The cost is a few extra pointers in the context; atom lookups walk the actual table count, so there's no per-lookup penalty. - Remote-VA relocation deref fix. Device provisioning relocates a RAM copy of the combined prelude for the flash VA it will be mapped at later — an address not yet mapped. Upstream's relocation dereferenced the not-yet-mapped new base and hung the watchdog on-glass; the fix dereferences the referenced block at its current address while storing the new-base pointer. For the in-place case the two addresses coincide, so behavior is byte-identical there.
- Copy-on-promote string interning. Interning a not-yet-unique string upstream mutates the input string in place. For bytecode run in place from read-only flash, an app that uses a constant-pool string as a property key would write into flash → an ESP32
LoadStoreErroron-glass. The fix canonicalizes into a fresh RAM copy and returns that — the flash-resident analogue of the "don't mutate ROM" rule.
Adapter & prelude conventions (not engine changes). Three behaviors shape the boot but live in the C adapter or the JS prelude, not the vendored engine:
- Load-all-then-run-all boot ordering. Running a loaded prelude interns atoms into RAM, after which
JS_LoadBytecoderefuses to load more ("no atom must be defined in RAM"). So the boot maps and loads every module in place first, then runs them — an invariant enforced in the adapter (paired with the atom-table sizing above). - The
eval_bundlebase dispatch. The adapter'skog_js_eval_bundlebranches on the bytecode header'sbase_addr: relocated-for-where-it-sits → load in place (don't rewrite read-only flash);base_addr == 0→ a base-0 image in writable RAM (the simulator and thekog devhot-reload push) → self-relocate for the current address; any other base → a deploy base mismatch → fail loud rather than fault on read-only flash. - The runtime-prelude Symbol shim. The net prelude installs
Symbol.iterator/asyncIteratoron its streams; a small shim in the JS prelude no-ops those installs (real iterator-protocol iteration isn't supported, and thefetch/JSON path doesn't need it).
Status
MQuickJS ships in every prebuilt image now (it is the only engine):
- Desktop simulator: all six example apps (counter, gallery, tic-tac-toe, flatlist-stress, hardware-demo, net-dashboard) mount and render real LVGL widget trees on MQuickJS.
- Live CYD hardware: gallery and net-dashboard render, running in place from read-only flash — the first Kog apps to do so on a bare ESP32. Post-init free heap is 96,796 B (~95 KB) with net-dashboard running stably, versus 3,636 B under the retired quickjs-ng runtime — the run-in-place win that makes networking and the ~16 KB hot-reload dev-link fit on the CYD at all.
- Pending: the live networking smoke test (net-dashboard renders its offline/error branch on the bench because that board had no Wi-Fi credentials provisioned; a
fetchagainst real data needs Wi-Fi creds), and hardware bring-up of the ESP32-S3/Waveshare prebuilt (built and link-clean from the current source, but no S3 board was on the bench at release). Final RAM slimming and the broader on-device convergence are ongoing.
For the full derivation, the RAM benchmarks, the deploy/relocation layout, and the complete gap analysis, see native/vendor/mquickjs/PROVENANCE.md and spec/16-rom-bytecode-inplace.md in the repository.