active-bezel
v0.10.1
Published
Active Bezels: executable companions to a specific ROM. A .ab package runs once per emulated frame, reads the emulator's live memory regions, and renders the complete final scene — maps, HUDs, reconstructed world graphics, artwork — around or over the ori
Maintainers
Readme
Active Bezels
An Active Bezel is an optional executable companion to a specific ROM.
It ships as a .ab file — an ordinary ZIP holding a manifest, a main.wasm
guest, and optional assets. It starts with the ROM, runs once per emulated
frame, reads the emulator's live memory regions, and renders the complete
final scene.
Despite the name, it is not limited to decorating empty side panels. The package owns the whole output picture: it can centre the original game, push it to one side, draw a live map of the world around it, overlay a HUD, or replace the picture entirely.

Every pixel of that 1920×1080 frame is drawn by the bezel: a photographed room, the live game mapped onto the curved glass with a multi-pass CRT shader, and the photo's own alpha trimming the picture to the tube. The game is Nova the Squirrel, an open-source NES homebrew.
poll input → run one core frame → run the bezel tick for that frame
│
reads the state the core just produced,
may write memory back, emits its scene
│
composite and presentThe core tick and the bezel tick are ordered on the same host frame. Nothing is asynchronous, so the visible frame and the state used to enhance it are deterministic.
Two things a package can do that nothing downstream of the framebuffer can:
Take layers apart. The redraw emits the empty backdrop, the solid tiles and the sprites as separate batches, and a draw can route each to its own surface. The PPU draws the level geometry and the open sky as one layer, so a filter over the finished picture sees them as the same pixels and must treat them identically -- which is exactly why post-processing reads as a wash laid over a game rather than as the game changing.
Take one entity out. hide_cell / hide_sprite drop a background cell
or an OAM slot from the render entirely, and isolate_sprite renders only
the marked slots onto a surface of your choosing. Entities are identified by
slot, which is live machine state: colour cannot do it, because a game is
free to draw the player and several unrelated entities from the same sprite
palette -- and often does.
What this repository is
The format specification, the reference runtime, and the packaging tool.
It deliberately does not belong to any one emulator. Two hosts consume it today:
- retroemu renders Active Bezels to a window.
- romdev composites them headlessly, so an agent can capture the composite, the raw core framebuffer, and the guest's own command stream for the same frame — which is what makes a package verifiable rather than merely plausible.
| | |
|---|---|
| docs/ACTIVE_BEZELS.md | the format and ABI |
| docs/HD_TEXT.md | replacing a game's 8-bit font with real letterforms |
| sdk/active_bezel.h | C header for guests |
| sdk/abi.json | machine-readable ABI |
| bin/abtool.js | scaffold / verify / inspect / pack |
| runtimes/ | prebuilt Lua / Python / JS / Ruby guests |
Install
npm install active-bezelimport { ActiveBezelRuntime } from 'active-bezel';
const runtime = await ActiveBezelRuntime.create({
packagePath: 'Game.ab',
host, // your emulator host
romBytes, // for exact hash matching
platform: 'nes',
});
// after each core frame:
const composite = runtime.processFrame(gameRgba, gameW, gameH, frameNumber);The runtime accepts either embedder shape — a host exposing the Emscripten
module as .core or as .mod — so you should not need an adapter. See
src/Host.js if you are wiring a third.
Authoring a package
With no toolchain at all. The package ships four prebuilt runtimes; copy one next to a script and you have a bezel:
| Runtime | Language | Size | Script | Docs |
|---|---|---|---|---|
| runtimes/lua/ | Lua 5.4 | 418 KB | main.lua | Lua bezels |
| runtimes/python/ | MicroPython | 306 KB | main.py | Python bezels |
| runtimes/js/ | QuickJS (ES2023) | 598 KB | main.js | JavaScript bezels |
| runtimes/ruby/ | mruby 3.4 | 640 KB | main.rb | Ruby bezels |
Each links to its own README: quick start, the full API by area, and the shapes that differ in that language. Overview of all four.
All four expose the identical API -- including offscreen surfaces, GLSL effects
and multi-pass .glslp presets -- so a bezel ports between them by changing
syntax, not capability.
All four runtimes also ship the platform redraw profiles (nes, gb,
md, snes, msx, pce; Ruby spells them NES..PCE): renderers that
rebuild the game picture from the core's live memory regions instead of
sampling its framebuffer, so a bezel can substitute sprite art, restyle
layers or extend the playfield while everything untouched stays
pixel-identical to the emulator. The renderers are one shared C core linked
into every runtime, so the same call produces the same pixels in every
language. Their exactness is measured against real-cart corpora, and every
rendering rule they encode has a must-fail control in the test suite. See
Platform redraw profiles.
mkdir -p my-bezel/assets
cp node_modules/active-bezel/runtimes/python/main.wasm my-bezel/
cp node_modules/active-bezel/runtimes/python/main.py my-bezel/ # the scaffold
$EDITOR my-bezel/main.pyEach runtime's scaffold is a working bezel with a commented example of every capability: 2D shapes, the live game, TrueType and bitmap text, live memory reads, transforms, a decoded PNG, a tilted perspective quad, a per-vertex mesh, and a GLSL effect.
def tick(frame):
ab.clear(ab.rgb(14, 16, 26))
ab.draw_game(0, 0, 1440, 1080, ab.SAMPLE['NEAREST'])
ram = ab.region('system_ram')
ab.draw_text(font, 'HP %d' % ab.read_u8(ram, 0x0E), 1500, 80, 40, ab.rgb(255, 255, 255))Iteration is edit, reload, look -- romdev loads an unpacked directory
directly, so nothing needs packing until you ship. Errors draw an on-screen
panel with the failing line instead of killing the session.
Then package it:
npx abtool verify my-bezel
npx abtool pack my-bezel my-bezel.ab
npx abtool inspect my-bezel.abpack emits a deterministic stored ZIP. .ab is deliberately ordinary — rename
it to .zip and look inside.
With a compiler: any language that emits WebAssembly.
A bezel is a wasm module. There is no privileged language and no runtime this package has to bless -- the contract is small enough to implement anywhere:
- import from one module,
ab_host - export five functions:
ab_abi_version,ab_init,ab_tick,ab_event,ab_shutdown(three more are optional, for guests that render their own framebuffer rather than emitting draw commands; a further optionalab_pre_frame— ABI 2 — runs BEFORE each core frame and may write live regions or override the input the core is about to see) - no WASI, no filesystem, no clock beyond what
ab_hosthands you
Anything that can produce a freestanding wasm32 module qualifies:
| Language | Notes |
|---|---|
| C / C++ | sdk/active_bezel.h is the reference binding. npx abtool scaffold my-bezel c starts one. |
| Rust | --target wasm32-unknown-unknown, #[no_mangle] exports, extern "C" imports |
| Zig | -target wasm32-freestanding, export fn |
| AssemblyScript | TypeScript-shaped, compiles straight to wasm |
| Go (TinyGo) | tinygo build -target wasm |
| Odin, Nim, D, ... | anything with a freestanding wasm backend |
A C guest gets the platform redraw profiles too, without an interpreter in
the middle: compile runtimes/common/ab_profiles.c plus the renderers
(ab_render.c, ab_nes.c, ab_gb.c, ab_md.c, ab_snes.c, ab_msx.c,
ab_pce.c) and ab_wasi_stubs.c into your module and call the
ab_prof_* API from runtimes/common/ab_profiles.h
directly -- it is the exact core the four language runtimes link, so the
pixels are identical by construction. The profile core uses libc, so build
with emcc (-s STANDALONE_WASM=1 --no-entry) rather than the scaffold's
-nostdlib line; a complete NES redraw bezel lands around 15 KB of wasm.
The full ABI is machine-readable in sdk/abi.json -- every host
import and guest export with its signature -- so a binding for a new language is
a mechanical translation. A guest imports only what it uses: the prebuilt
runtimes take 55 of the 58 available.
The four prebuilt runtimes above are themselves just wasm guests built this way, from C. They exist because most bezels are not worth a compile step, not because scripting is the supported path and compiling is not.
When a bezel breaks
A script error never takes the session down, and it is never silent. It is reported three ways:
- On screen -- a panel across the top half of the frame in an embedded TrueType face, on an opaque backing, with the game still running underneath so the failure stays in context. The font is compiled into the runtime: the panel exists for when the package is broken, so it cannot depend on the package shipping one.
- On stderr, prefixed
AB-ERROR:, with no debug flag required. Ordinaryab.logoutput stays behindRETROEMU_DEBUG/AB_LOG-- a crash is not ordinary logging.AB_SILENT=1suppresses it if your host surfaces the error itself. - Programmatically, as
runtime.status().scriptError.
That third channel is the one that matters for tooling, and it is worth
understanding why it exists. A script error is caught by its own runtime, so
the host's processFrame call returns normally -- the host-side error
field stays null and every automated health check passes while the screen
shows a stack trace. Anything that decides "is this bezel working?"
programmatically has to read scriptError, not error. romdev surfaces it
as BEZEL_SCRIPT_ERROR.
Reloading clears the latch, so a fixed script recovers without restarting the host.
Dependencies
native-gles is required — the hosts that consume this package need GL to run.
It is still bound lazily, because a native addon can be installed but
unloadable (a missing prebuild, a failed install script, a Node ABI mismatch),
and that must not take down package loading, manifest validation or the CPU
compositor, none of which need GL. When GL is unavailable for any reason the
runtime falls back to the CPU compositor, which is fully featured; the GL path
is a performance option, not a capability one.
wasmcart is genuinely optional and loads on demand: only a guest declaring
the legacy runtime.language: "lua54-wasmcart" needs it. The four prebuilt
runtimes are plain wasm guests and never touch it.
Shader presets
Bezels can run RetroArch .glslp shader presets -- the multi-pass CRT chains,
not just single shaders -- into an offscreen surface, then map the result
anywhere in the scene.
No shaders ship with this package. Point it at
libretro/glsl-shaders, or at the
copy an existing RetroArch install already has
(~/.config/retroarch/shaders/shaders_glsl/).
491 of the 609 shipped presets run (81%), including 309 of the 377
multi-pass ones -- 150/150 handheld, 61/75 crt. The rest use desktop-OpenGL
constructs that GLES 3 rejects; presets are used exactly as published and their
source is never edited, so those stay unsupported. crt-royale is one of them.
Licensing is why nothing is bundled: the upstream repository has no repository-level licence and the per-file terms are a mix of GPL, MIT, public domain, and no grant at all. Loading from a copy the user already has keeps that between the user and the shader author.
Full detail, including the per-category table and what specifically fails, is in docs/ACTIVE_BEZELS.md.
A caution worth repeating
A .ab can load cleanly, tick without trapping, and emit perfectly valid draw
commands while being completely wrong about the game.
That is not hypothetical. An early package for a maze game declared in its
profile.json that it read the player's room, X and Y; its readable main.c
contained room-aware logic; and the compiled main.wasm shipped alongside them
ignored the room byte entirely and plotted raw coordinates across a fake map,
with two diagnostic bars that looked convincingly like progress meters. Unit
tests proved the package loaded and drew. They proved nothing about whether the
picture meant anything.
So: validate that the guest reads the regions it claims to, correlate its output
against live state over a real play trace, and treat profile.json labels as
research leads until evidence says otherwise. romdev's command and
region-access tracing exists specifically to make this class of error visible.
Licence
MIT — see LICENSE.
The bundled example packages (diagnostic, lua-starter) are generic: they
contain no game content and target no specific ROM.
No third-party shaders are redistributed here. src/imgdec.wasm is built from
the stb_image.h vendored in runtimes/common (public domain / MIT dual
licence); see tools/imgdec/SOURCES.md.
