Skip to content
alan-carrollPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ale-rs

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 indices

Three 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.

Building

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 test

Three unrecoverable ALE paths this crate stands in front of

ALE does not always signal failure recoverably, and a library that let those through would take a caller's whole process down:

  • ALEInterface::loadROM (and the loadSettings it calls) end their failure branches with std::exit(1) — missing file, unreadable file, unrecognized cartridge. load_rom runs ALEInterface::isSupportedROM first, which reports the same conditions recoverably, and only then loads. This is stricter than ale-py, which merely warns on an MD5 mismatch.
  • ALEState::apply_action's default: arm is std::exit(-1). act rejects any value outside 0..=17 (the PLAYER_A_* range) before the call. Values inside that range but illegal for the loaded ROM are folded to NOOP by ALE itself, exactly as under ale-py.
  • Until loadROM runs, ALEInterface holds its environment as a null unique_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 returns AleError::NoRomLoaded instead.

One branch remains uncovered: a cartridge whose MD5 is known but whose console Stella then fails to create still exits.

Performance

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: NEON tbl kernels 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 the screen_conversions_are_pointwise_and_survive_reloading test guards the invalidation seam on whatever machine runs the suite. An AVX2 kernel would roughly double the x86 conversion rate (nibble-split pshufb); deliberately not shipped until it can be validated on x86 hardware.
  • How libale.a is compiled matters only at the margins. -mcpu=native and ThinLTO both measured within noise. Profile-guided optimization was the one real compiler lever: +2–16% on act() depending on the game. Build with -DCMAKE_CXX_FLAGS="-fprofile-generate=$PWD/pgo", run a representative workload (linking from Rust needs RUSTFLAGS="-C link-arg=-fprofile-generate=$PWD/pgo" so the profile runtime links), merge with llvm-profdata merge -output=ale.profdata pgo/*.profraw, then rebuild with -DCMAKE_CXX_FLAGS="-fprofile-use=$PWD/ale.profdata".

Threading

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages