play96 0.3.2

Programmable libretro harness for *96 fantasy-console cores
Documentation

play96

play96 is a Rust library and CLI for running *96 libretro fantasy-console cores in automated test harnesses. It can step cartridges frame by frame, control input, inspect framebuffer pixels, make assertions, capture PNG files, exercise save states, and compare deterministic frame logs.

Build and test

git submodule update --init --recursive
cargo test
cargo build --release

The build produces the play96-cli executable and Rust, C, and C++ compatible libraries. C headers are in include/.

Rust library

Add the repository dependency to a test harness or library:

[dev-dependencies]
play96 = { git = "https://codeberg.org/compu96/play96.git" }

For a packaged release, use play96 = "0.3" after publishing it to the configured Cargo registry.

use play96::{Color, InputDevice, MouseButton, Session, key};

fn main() -> Result<(), play96::Error> {
    let mut game = Session::new("wasm96_libretro.dll", "cartridge.wasm")?;
    game.set_controller_device(0, InputDevice::Mouse);
    game.set_key(key::SPACE, true);
    game.set_mouse_delta(4, -2);
    game.set_mouse_button(MouseButton::Left, true);
    game.set_mouse_wheel(1, 0);
    game.run_frames(10)?;

    assert_eq!(game.framebuffer_width(), 320);
    game.assert_pixel(12, 20, Color::rgb(255, 0, 0))?;
    game.assert_pixel_xrgb(13, 20, 0x00ff00)?;
    assert_eq!(game.frame_hash(), 0x0123_4567_89ab_cdef);

    // Audio is interleaved signed 16-bit stereo PCM from the latest frame.
    assert_eq!(game.audio_sample_rate(), 48_000.0);
    game.assert_audio_sample(100, 0, 1200, 10)?;
    game.assert_audio_hash(0xfedc_ba98_7654_3210)?;
    let left_sample = game.audio_frame(100)?[0];
    assert_eq!(left_sample, game.audio_samples()[200]);
    game.write_png("actual.png")?;
    Ok(())
}

pixel and pixel_xrgb return colors for custom assertions. audio_samples returns the latest frame's interleaved stereo waveform, while audio_frame, assert_audio_sample, and assert_audio_hash support exact deterministic sound tests. Failed built-in assertions report expected and actual values.

Keyboard input accepts libretro key codes through set_key; common codes are available in the key module. Mouse movement and wheel values are relative and apply to the next frame, while mouse buttons remain pressed until changed or cleared. Select InputDevice::Mouse for mouse-oriented cores; joypad remains the default device.

Only one Session may execute at a time because libretro callbacks are global to the dynamically loaded core.

C++ wrapper

Link against the generated play96 library and include include/play96.hpp:

#include <play96.hpp>

int main() {
    play96::Session game("wasm96_libretro.dll", "cartridge.wasm");
    game.run_frames(10);
    game.assert_pixel(12, 20, {255, 0, 0});
    game.assert_pixel_xrgb(13, 20, 0x00ff00);
}

The wrapper throws std::runtime_error when loading, execution, or an assertion fails. include/play96.h provides the underlying exception-free C ABI for other languages.

CLI

play96-cli --core ../wasm96/zig-out/bin/wasm96_libretro.dll \
  --cart ../wasm96/templates/cartridge-c/cartridge.wasm \
  --device mouse \
  --frames 30 --shot-every 15 \
  --macro "0-10:key.space+mouse.left+mouse.dx=4+mouse.dy=-2" \
  --script input.rhai \
  --assert-pixel 12,20,ff0000 \
  --assert-audio-sample 100,0,1200,10 \
  --assert-audio-hash fedcba9876543210 \
  --netplay-log wasm96.netplay

Screenshots are written as PNG files. A recorded netplay log can be checked by running the same command with --netplay-check instead of --netplay-log.

--macro specifies fixed frame ranges. For conditional input, pass a Rhai file with --script; it must define fn frame(frame). Each call may use button("a"), key("space"), mouse_button("left"), mouse_delta(x, y), and mouse_wheel(vertical, horizontal). Script input is combined with macro input for that frame. For example:

fn frame(frame) {
    if frame % 30 == 0 {
        button("a");
        mouse_delta(4, -2);
    }
}

Captured RGB565, 0RGB1555, and XRGB8888 frames are normalized to XRGB8888, so pixel assertions, framebuffer reads, hashes, and PNG captures use the same 0x00RRGGBB values regardless of the core's native video format.