A small Rust binding for the Arcade Learning Environment's C++
ALEInterface, for driving Atari 2600 emulation.
Upstream ships no C wrapper as of v0.12.0 (the old ale_c_wrapper.h is gone),
so this crate carries its own extern "C" shim (csrc/ale_shim.cpp) over about
a dozen ALEInterface calls and wraps it in a safe Rust API.
use ale_rs::Ale;
use std::path::Path;
let mut ale = Ale::new()?;
ale.set_int("random_seed", 12345)?;
ale.set_float("repeat_action_probability", 0.25)?;
ale.set_int("max_num_frames_per_episode", 108_000)?;
ale.load_rom(Path::new("/path/to/bowling.bin"))?;
ale.reset_game()?;
let actions = ale.minimal_action_set()?;
let reward = ale.act(actions[0], 1.0)?;
let terminated = ale.game_over(false)?;
let truncated = ale.game_truncated()?;
let screen = ale.screen()?; // borrowed, 210 * 160 palette indicesThree screen views are available, all borrowed from reused buffers so a steady
render loop allocates nothing: screen() (palette indices), screen_grayscale()
(one byte per pixel), and screen_rgb() (three bytes per pixel — the same bytes
ale_py's render() returns in rgb_array mode). get_int / get_float read
a setting back out of the live interface, so a caller can verify what it
configured instead of trusting its own record; like the setters, they work
before a ROM is loaded.
ALE is a C++ library you build separately. Point ALE_ROOT at a CMake install
prefix holding include/ale/ale_interface.hpp and lib/libale.a:
git clone https://github.com/Farama-Foundation/Arcade-Learning-Environment.git
cd Arcade-Learning-Environment && git checkout v0.12.0 && cd ..
cmake -S Arcade-Learning-Environment -B ale-build -DCMAKE_BUILD_TYPE=Release \
-DBUILD_PYTHON_LIB=OFF -DBUILD_VECTOR_LIB=OFF -DBUILD_VECTOR_XLA_LIB=OFF \
-DBUILD_WASM_LIB=OFF -DSDL_SUPPORT=OFF \
-DCMAKE_INSTALL_PREFIX="$PWD/ale-install"
cmake --build ale-build --parallel --target install
export ALE_ROOT="$PWD/ale-install"With those options zlib is the only external dependency.
ALE_ROOT is optional. Unset, the crate still builds; ale_rs::is_linked()
returns false and every Ale::new() returns AleError::NotLinked, so a
consumer's test suite can skip instead of failing on a machine without a C++
toolchain. build.rs drives $CXX and $AR directly, so the crate has no
dependencies at all.
No ROMs are distributed here. load_rom takes a path; supply your own.
The test suite's happy-path smoke is gated the same way: it runs only when
ALE_TEST_ROM points at a supported cartridge, and skips otherwise. The
upstream source checkout ships tests/resources/tetris.bin — 2600 homebrew
present in ALE's MD5 table — which works:
ALE_TEST_ROM="$PWD/Arcade-Learning-Environment/tests/resources/tetris.bin" cargo testALE does not always signal failure recoverably, and a library that let those through would take a caller's whole process down:
ALEInterface::loadROM(and theloadSettingsit calls) end their failure branches withstd::exit(1)— missing file, unreadable file, unrecognized cartridge.load_romrunsALEInterface::isSupportedROMfirst, which reports the same conditions recoverably, and only then loads. This is stricter thanale-py, which merely warns on an MD5 mismatch.ALEState::apply_action'sdefault:arm isstd::exit(-1).actrejects any value outside0..=17(thePLAYER_A_*range) before the call. Values inside that range but illegal for the loaded ROM are folded to NOOP by ALE itself, exactly as underale-py.- Until
loadROMruns,ALEInterfaceholds itsenvironmentas a nullunique_ptr, and every post-ROM method (act,reset_game,getScreen, …) dereferences it unconditionally — a segfault, which no exception guard can catch. The wrapper tracks whether a ROM has loaded and returnsAleError::NoRomLoadedinstead.
One branch remains uncovered: a cartridge whose MD5 is known but whose console Stella then fails to create still exits.
Numbers below are from one machine (Apple M4 Pro, ALE v0.12 built -O3
Release, Ms. Pac-Man unless noted) — treat them as shape, not gospel.
Where a frame's time goes: ~97% of act() is inside libale.a itself —
TIA::updateFrame (~58%) and the 6502 core (~31%) under a sampling profiler —
and the shim plus FFI boundary cost nanoseconds. So the levers, largest first:
act()runs 36–59 µs/frame depending on the game (17–28k frames/s for a single instance).- Instances scale near-linearly across performance cores (see Threading): one instance ~20k frames/s stepping with a grayscale observation, ~136k aggregate on 8 threads, ~158k on 12 (8P+4E cores). No shared state, no contention.
screen()is free — a borrowed pointer into the emulator's buffer.screen_grayscale()/screen_rgb()bypass ALE's conversion loops (csrc/ale_shim.cpp), which decompose a 32-bit palette entry with shifts for every pixel. The shim snapshots the palette into byte tables after each ROM load and applies those instead: NEONtblkernels on aarch64 (~2.3 µs and ~7.2 µs per frame versus ~11.5 and ~19.1 through ALE's loops, making a step-plus-observe loop ~23-26% faster end to end), and plain lookup loops everywhere else (byte-to-byte walk for grayscale, one four-byte overlapping store per pixel for RGB — measured ~1.9x and ~2.8x under Rosetta translation, so treat those as directional). Output is byte-identical on both paths — verified against ALE's loops over thousands of deterministic frames across five ROMs (the x86 path under Rosetta), and thescreen_conversions_are_pointwise_and_survive_reloadingtest guards the invalidation seam on whatever machine runs the suite. An AVX2 kernel would roughly double the x86 conversion rate (nibble-splitpshufb); deliberately not shipped until it can be validated on x86 hardware.- How
libale.ais compiled matters only at the margins.-mcpu=nativeand ThinLTO both measured within noise. Profile-guided optimization was the one real compiler lever: +2–16% onact()depending on the game. Build with-DCMAKE_CXX_FLAGS="-fprofile-generate=$PWD/pgo", run a representative workload (linking from Rust needsRUSTFLAGS="-C link-arg=-fprofile-generate=$PWD/pgo"so the profile runtime links), merge withllvm-profdata merge -output=ale.profdata pgo/*.profraw, then rebuild with-DCMAKE_CXX_FLAGS="-fprofile-use=$PWD/ale.profdata".
Ale is Send but not Sync and not Clone. The unsafe impl Send is
justified by single-owner semantics — ALE holds its emulator state in
per-instance unique_ptrs — and the one process-global observed, the logger
mode, is reachable only through the free-standing set_logger_mode.
That justification is worth testing rather than trusting, and it takes two
tests because it is two properties. For interference, step N instances
round-robin on one thread and compare against the same N run alone. For
transfer — what Send actually licenses — build and step N instances on N
threads, and move one across a thread boundary before stepping it. Round-robin
on a single thread does not exercise transfer, and a caller that spawns an
evaluation thread per environment exercises both.
Constructing the first Ale in a process quiets ALE's logger to Error unless
set_logger_mode was called first, because ALEInterface's constructor prints
a banner at Info.