#![allow(clippy::missing_safety_doc)]
use libloading::Library;
use std::ffi::{CStr, CString, c_char, c_void};
use std::fs;
use std::path::Path;
use std::ptr;
use std::sync::{Mutex, MutexGuard};
const RETRO_API_VERSION: u32 = 1;
const RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: u32 = 10;
const RETRO_ENVIRONMENT_SET_GEOMETRY: u32 = 37;
const RETRO_PIXEL_FORMAT_XRGB8888: u32 = 1;
const RETRO_DEVICE_JOYPAD: u32 = 1;
const MAX_DIMENSION: usize = 2048;
const BUTTON_COUNT: usize = 12;
#[repr(C)]
struct RetroGameInfo {
path: *const c_char,
data: *const c_void,
size: usize,
meta: *const c_char,
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct RetroGameGeometry {
base_width: u32,
base_height: u32,
max_width: u32,
max_height: u32,
aspect_ratio: f32,
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct RetroSystemTiming {
fps: f64,
sample_rate: f64,
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct RetroSystemAvInfo {
geometry: RetroGameGeometry,
timing: RetroSystemTiming,
}
type EnvironmentCallback = unsafe extern "C" fn(u32, *mut c_void) -> bool;
type VideoCallback = unsafe extern "C" fn(*const c_void, u32, u32, usize);
type AudioCallback = unsafe extern "C" fn(i16, i16) -> usize;
type AudioBatchCallback = unsafe extern "C" fn(*const i16, usize) -> usize;
type InputPollCallback = unsafe extern "C" fn();
type InputStateCallback = unsafe extern "C" fn(u32, u32, u32, u32) -> i16;
type RetroApiVersion = unsafe extern "C" fn() -> u32;
type RetroSetEnvironment = unsafe extern "C" fn(EnvironmentCallback);
type RetroSetVideo = unsafe extern "C" fn(VideoCallback);
type RetroSetAudio = unsafe extern "C" fn(AudioCallback);
type RetroSetAudioBatch = unsafe extern "C" fn(AudioBatchCallback);
type RetroSetInputPoll = unsafe extern "C" fn(InputPollCallback);
type RetroSetInputState = unsafe extern "C" fn(InputStateCallback);
type RetroInit = unsafe extern "C" fn();
type RetroDeinit = unsafe extern "C" fn();
type RetroGetSystemAvInfo = unsafe extern "C" fn(*mut RetroSystemAvInfo);
type RetroSetControllerPortDevice = unsafe extern "C" fn(u32, u32);
type RetroLoadGame = unsafe extern "C" fn(*const RetroGameInfo) -> bool;
type RetroUnloadGame = unsafe extern "C" fn();
type RetroRun = unsafe extern "C" fn();
type RetroSerializeSize = unsafe extern "C" fn() -> usize;
type RetroSerialize = unsafe extern "C" fn(*mut c_void, usize) -> bool;
type RetroUnserialize = unsafe extern "C" fn(*const c_void, usize) -> bool;
type RetroGetMemoryData = unsafe extern "C" fn(u32) -> *mut c_void;
type RetroGetMemorySize = unsafe extern "C" fn(u32) -> usize;
struct CoreApi {
api_version: RetroApiVersion,
set_environment: RetroSetEnvironment,
set_video: RetroSetVideo,
set_audio: RetroSetAudio,
set_audio_batch: RetroSetAudioBatch,
set_input_poll: RetroSetInputPoll,
set_input_state: RetroSetInputState,
init: RetroInit,
deinit: RetroDeinit,
av_info: RetroGetSystemAvInfo,
set_port_device: RetroSetControllerPortDevice,
load_game: RetroLoadGame,
unload_game: RetroUnloadGame,
run: RetroRun,
serialize_size: RetroSerializeSize,
serialize: RetroSerialize,
unserialize: RetroUnserialize,
memory_data: RetroGetMemoryData,
memory_size: RetroGetMemorySize,
}
struct Capture {
pixels: Vec<u32>,
width: u32,
height: u32,
pixel_format: u32,
av: RetroSystemAvInfo,
pad: [u8; BUTTON_COUNT],
}
impl Default for Capture {
fn default() -> Self {
Self {
pixels: Vec::new(),
width: 0,
height: 0,
pixel_format: RETRO_PIXEL_FORMAT_XRGB8888,
av: RetroSystemAvInfo::default(),
pad: [0; BUTTON_COUNT],
}
}
}
static mut ACTIVE_CAPTURE: *mut Capture = ptr::null_mut();
static SESSION_LOCK: Mutex<()> = Mutex::new(());
unsafe extern "C" fn environment_callback(command: u32, data: *mut c_void) -> bool {
let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_mut() }) else {
return false;
};
match command {
RETRO_ENVIRONMENT_SET_PIXEL_FORMAT => {
if data.is_null() {
return false;
}
capture.pixel_format = unsafe { *(data as *const u32) };
capture.pixel_format == RETRO_PIXEL_FORMAT_XRGB8888
}
RETRO_ENVIRONMENT_SET_GEOMETRY => {
if data.is_null() {
return false;
}
capture.av.geometry = unsafe { *(data as *const RetroGameGeometry) };
true
}
_ => false,
}
}
unsafe extern "C" fn video_callback(data: *const c_void, width: u32, height: u32, pitch: usize) {
let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_mut() }) else {
return;
};
let width = width as usize;
let height = height as usize;
if width == 0
|| height == 0
|| width > MAX_DIMENSION
|| height > MAX_DIMENSION
|| pitch < width * 4
{
return;
}
capture.width = width as u32;
capture.height = height as u32;
if data.is_null() {
return;
}
capture.pixels.resize(width * height, 0);
let bytes = data as *const u8;
for y in 0..height {
let row = unsafe { std::slice::from_raw_parts(bytes.add(y * pitch) as *const u32, width) };
capture.pixels[y * width..(y + 1) * width].copy_from_slice(row);
}
}
unsafe extern "C" fn audio_callback(_: i16, _: i16) -> usize {
1
}
unsafe extern "C" fn audio_batch_callback(_: *const i16, frames: usize) -> usize {
frames
}
unsafe extern "C" fn input_poll_callback() {}
unsafe extern "C" fn input_state_callback(_: u32, device: u32, _: u32, id: u32) -> i16 {
if device != RETRO_DEVICE_JOYPAD {
return 0;
}
const MAP: [u32; BUTTON_COUNT] = [4, 5, 6, 7, 8, 0, 9, 1, 10, 11, 3, 2];
let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_ref() }) else {
return 0;
};
MAP.iter()
.position(|&button| button == id)
.map(|index| i16::from(capture.pad[index] != 0))
.unwrap_or(0)
}
unsafe fn symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T, Error> {
unsafe { library.get::<T>(name) }
.map(|symbol| *symbol)
.map_err(|error| {
Error::new(format!(
"missing libretro symbol {}: {error}",
String::from_utf8_lossy(&name[..name.len() - 1])
))
})
}
unsafe fn load_api(library: &Library) -> Result<CoreApi, Error> {
Ok(CoreApi {
api_version: unsafe { symbol(library, b"retro_api_version\0")? },
set_environment: unsafe { symbol(library, b"retro_set_environment\0")? },
set_video: unsafe { symbol(library, b"retro_set_video_refresh\0")? },
set_audio: unsafe { symbol(library, b"retro_set_audio_sample\0")? },
set_audio_batch: unsafe { symbol(library, b"retro_set_audio_sample_batch\0")? },
set_input_poll: unsafe { symbol(library, b"retro_set_input_poll\0")? },
set_input_state: unsafe { symbol(library, b"retro_set_input_state\0")? },
init: unsafe { symbol(library, b"retro_init\0")? },
deinit: unsafe { symbol(library, b"retro_deinit\0")? },
av_info: unsafe { symbol(library, b"retro_get_system_av_info\0")? },
set_port_device: unsafe { symbol(library, b"retro_set_controller_port_device\0")? },
load_game: unsafe { symbol(library, b"retro_load_game\0")? },
unload_game: unsafe { symbol(library, b"retro_unload_game\0")? },
run: unsafe { symbol(library, b"retro_run\0")? },
serialize_size: unsafe { symbol(library, b"retro_serialize_size\0")? },
serialize: unsafe { symbol(library, b"retro_serialize\0")? },
unserialize: unsafe { symbol(library, b"retro_unserialize\0")? },
memory_data: unsafe { symbol(library, b"retro_get_memory_data\0")? },
memory_size: unsafe { symbol(library, b"retro_get_memory_size\0")? },
})
}
#[derive(Debug, Clone)]
pub struct Error(String);
impl Error {
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl std::fmt::Display for Error {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
impl std::error::Error for Error {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Color {
pub red: u8,
pub green: u8,
pub blue: u8,
pub alpha: u8,
}
impl Color {
pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
Self {
red,
green,
blue,
alpha: 255,
}
}
pub const fn xrgb(self) -> u32 {
((self.red as u32) << 16) | ((self.green as u32) << 8) | self.blue as u32
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
pub enum Button {
Up = 0,
Down = 1,
Left = 2,
Right = 3,
A = 4,
B = 5,
X = 6,
Y = 7,
L1 = 8,
R1 = 9,
Start = 10,
Select = 11,
}
pub struct Session {
_session_guard: MutexGuard<'static, ()>,
library: Library,
api: CoreApi,
capture: Box<Capture>,
cartridge: Vec<u8>,
cartridge_path: CString,
loaded: bool,
}
impl Session {
pub fn new(
core_path: impl AsRef<Path>,
cartridge_path: impl AsRef<Path>,
) -> Result<Self, Error> {
let session_guard = SESSION_LOCK.try_lock().map_err(|_| {
Error::new(
"another play96 session is active; libretro cores can only run one at a time",
)
})?;
let library = unsafe { Library::new(core_path.as_ref()) }
.map_err(|error| Error::new(format!("failed to load core: {error}")))?;
let api = unsafe { load_api(&library)? };
if unsafe { (api.api_version)() } != RETRO_API_VERSION {
return Err(Error::new("unsupported libretro API version"));
}
let cartridge = fs::read(cartridge_path.as_ref())
.map_err(|error| Error::new(format!("failed to read cartridge: {error}")))?;
let cartridge_path = CString::new(cartridge_path.as_ref().to_string_lossy().as_bytes())
.map_err(|_| Error::new("cartridge path contains a NUL byte"))?;
let mut session = Self {
_session_guard: session_guard,
library,
api,
capture: Box::default(),
cartridge,
cartridge_path,
loaded: false,
};
session.activate();
unsafe {
(session.api.set_environment)(environment_callback);
(session.api.set_video)(video_callback);
(session.api.set_audio)(audio_callback);
(session.api.set_audio_batch)(audio_batch_callback);
(session.api.set_input_poll)(input_poll_callback);
(session.api.set_input_state)(input_state_callback);
(session.api.init)();
(session.api.set_port_device)(0, RETRO_DEVICE_JOYPAD);
}
let game = RetroGameInfo {
path: session.cartridge_path.as_ptr(),
data: session.cartridge.as_ptr().cast(),
size: session.cartridge.len(),
meta: ptr::null(),
};
if !unsafe { (session.api.load_game)(&game) } {
return Err(Error::new("failed to load cartridge"));
}
session.loaded = true;
unsafe { (session.api.av_info)(&mut session.capture.av) };
Ok(session)
}
fn activate(&mut self) {
unsafe { ACTIVE_CAPTURE = self.capture.as_mut() };
}
pub fn run_frame(&mut self) -> Result<(), Error> {
self.activate();
unsafe { (self.api.run)() };
Ok(())
}
pub fn run_frames(&mut self, frames: usize) -> Result<(), Error> {
for _ in 0..frames {
self.run_frame()?;
}
Ok(())
}
pub fn set_button(&mut self, button: Button, pressed: bool) {
self.capture.pad[button as usize] = u8::from(pressed);
}
pub fn clear_buttons(&mut self) {
self.capture.pad = [0; BUTTON_COUNT];
}
pub fn framebuffer_width(&self) -> u32 {
self.capture.width
}
pub fn framebuffer_height(&self) -> u32 {
self.capture.height
}
pub fn framebuffer(&self) -> &[u32] {
&self.capture.pixels
}
pub fn frame_hash(&self) -> u64 {
fnv1a_u32(&self.capture.pixels)
}
pub fn save_ram_hash(&mut self) -> u64 {
self.activate();
const RETRO_MEMORY_SAVE_RAM: u32 = 0;
let size = unsafe { (self.api.memory_size)(RETRO_MEMORY_SAVE_RAM) };
let data = unsafe { (self.api.memory_data)(RETRO_MEMORY_SAVE_RAM) };
if size == 0 || data.is_null() {
return 0;
}
let bytes = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), size) };
fnv1a(bytes)
}
pub fn pixel_xrgb(&self, x: u32, y: u32) -> Result<u32, Error> {
if x >= self.capture.width || y >= self.capture.height {
return Err(Error::new(format!(
"pixel ({x}, {y}) is outside {}x{} framebuffer",
self.capture.width, self.capture.height
)));
}
Ok(self.capture.pixels[y as usize * self.capture.width as usize + x as usize])
}
pub fn pixel(&self, x: u32, y: u32) -> Result<Color, Error> {
let value = self.pixel_xrgb(x, y)?;
Ok(Color::rgb(
(value >> 16) as u8,
(value >> 8) as u8,
value as u8,
))
}
pub fn assert_pixel(&self, x: u32, y: u32, expected: Color) -> Result<(), Error> {
let actual = self.pixel(x, y)?;
if actual == expected {
Ok(())
} else {
Err(Error::new(format!(
"pixel ({x}, {y}) expected #{:02x}{:02x}{:02x}{:02x}, got #{:02x}{:02x}{:02x}{:02x}",
expected.red,
expected.green,
expected.blue,
expected.alpha,
actual.red,
actual.green,
actual.blue,
actual.alpha
)))
}
}
pub fn assert_pixel_xrgb(&self, x: u32, y: u32, expected: u32) -> Result<(), Error> {
let actual = self.pixel_xrgb(x, y)?;
if actual == expected {
Ok(())
} else {
Err(Error::new(format!(
"pixel ({x}, {y}) expected #{expected:06x}, got #{actual:06x}"
)))
}
}
pub fn save_state(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
self.activate();
let size = unsafe { (self.api.serialize_size)() };
if size == 0 {
return Err(Error::new("core reported an empty save state"));
}
let mut state = vec![0; size];
if !unsafe { (self.api.serialize)(state.as_mut_ptr().cast(), state.len()) } {
return Err(Error::new("failed to save state"));
}
fs::write(path, state)
.map_err(|error| Error::new(format!("failed to write state: {error}")))
}
pub fn load_state(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
self.activate();
let state =
fs::read(path).map_err(|error| Error::new(format!("failed to read state: {error}")))?;
if unsafe { (self.api.unserialize)(state.as_ptr().cast(), state.len()) } {
Ok(())
} else {
Err(Error::new("failed to load state"))
}
}
pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
if self.capture.pixels.is_empty() {
return Err(Error::new("no framebuffer has been received"));
}
let file = fs::File::create(path)
.map_err(|error| Error::new(format!("failed to create PNG: {error}")))?;
let mut encoder = png::Encoder::new(file, self.capture.width, self.capture.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|error| Error::new(format!("failed to write PNG header: {error}")))?;
let mut rgba = Vec::with_capacity(self.capture.pixels.len() * 4);
for pixel in &self.capture.pixels {
rgba.extend_from_slice(&[(pixel >> 16) as u8, (pixel >> 8) as u8, *pixel as u8, 255]);
}
writer
.write_image_data(&rgba)
.map_err(|error| Error::new(format!("failed to write PNG: {error}")))
}
}
fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(1_469_598_103_934_665_603, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(1_099_511_628_211)
})
}
fn fnv1a_u32(values: &[u32]) -> u64 {
let mut hash = 1_469_598_103_934_665_603_u64;
for value in values {
for byte in value.to_ne_bytes() {
hash = (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211);
}
}
hash
}
impl Drop for Session {
fn drop(&mut self) {
self.activate();
unsafe {
if self.loaded {
(self.api.unload_game)();
}
(self.api.deinit)();
ACTIVE_CAPTURE = ptr::null_mut();
}
let _ = &self.library;
}
}
#[repr(C)]
pub struct Play96Color {
pub red: u8,
pub green: u8,
pub blue: u8,
pub alpha: u8,
}
#[repr(C)]
pub struct play96_session {
session: Session,
error: CString,
}
fn c_error(error: impl std::fmt::Display) -> *const c_char {
thread_local! { static ERROR: std::cell::RefCell<CString> = std::cell::RefCell::new(CString::new("unknown error").unwrap()); }
ERROR.with(|slot| {
*slot.borrow_mut() = CString::new(error.to_string())
.unwrap_or_else(|_| CString::new("error contained a NUL byte").unwrap());
slot.borrow().as_ptr()
})
}
unsafe fn required_string<'a>(value: *const c_char, name: &str) -> Result<&'a str, *const c_char> {
if value.is_null() {
return Err(c_error(format!("{name} must not be null")));
}
unsafe { CStr::from_ptr(value) }
.to_str()
.map_err(|_| c_error(format!("{name} is not valid UTF-8")))
}
fn session_result(session: *mut play96_session, result: Result<(), Error>) -> *const c_char {
match result {
Ok(()) => ptr::null(),
Err(error) => unsafe {
if session.is_null() {
c_error(error)
} else {
(*session).error = CString::new(error.to_string()).unwrap();
(*session).error.as_ptr()
}
},
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_create(
core: *const c_char,
cartridge: *const c_char,
out: *mut *mut play96_session,
) -> *const c_char {
if out.is_null() {
return c_error("out_session must not be null");
}
let Ok(core) = (unsafe { required_string(core, "core_path") }) else {
return c_error("invalid core_path");
};
let Ok(cartridge) = (unsafe { required_string(cartridge, "cartridge_path") }) else {
return c_error("invalid cartridge_path");
};
match Session::new(core, cartridge) {
Ok(session) => {
unsafe {
*out = Box::into_raw(Box::new(play96_session {
session,
error: CString::new("").unwrap(),
}));
}
ptr::null()
}
Err(error) => c_error(error),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_destroy(session: *mut play96_session) {
if !session.is_null() {
unsafe {
drop(Box::from_raw(session));
}
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_run_frame(session: *mut play96_session) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
unsafe { session_result(session, (*session).session.run_frame()) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_run_frames(
session: *mut play96_session,
frames: usize,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
unsafe { session_result(session, (*session).session.run_frames(frames)) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_set_button(
session: *mut play96_session,
port: u32,
button: u32,
pressed: i32,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
if port != 0 || button as usize >= BUTTON_COUNT {
return c_error("only port 0 and buttons 0 through 11 are supported");
}
unsafe {
(*session).session.capture.pad[button as usize] = u8::from(pressed != 0);
}
ptr::null()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_clear_buttons(
session: *mut play96_session,
port: u32,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
if port != 0 {
return c_error("only port 0 is supported");
}
unsafe {
(*session).session.clear_buttons();
}
ptr::null()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_framebuffer_width(session: *const play96_session) -> u32 {
if session.is_null() {
0
} else {
unsafe { (*session).session.framebuffer_width() }
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_framebuffer_height(session: *const play96_session) -> u32 {
if session.is_null() {
0
} else {
unsafe { (*session).session.framebuffer_height() }
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_pixel_xrgb(session: *const play96_session, x: u32, y: u32) -> u32 {
if session.is_null() {
return 0;
}
unsafe { (*session).session.pixel_xrgb(x, y).unwrap_or(0) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_assert_pixel(
session: *const play96_session,
x: u32,
y: u32,
expected: Play96Color,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
let expected = Color {
red: expected.red,
green: expected.green,
blue: expected.blue,
alpha: expected.alpha,
};
unsafe {
(*session)
.session
.assert_pixel(x, y, expected)
.map(|_| ptr::null())
.unwrap_or_else(c_error)
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_assert_pixel_xrgb(
session: *const play96_session,
x: u32,
y: u32,
expected: u32,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
unsafe {
(*session)
.session
.assert_pixel_xrgb(x, y, expected)
.map(|_| ptr::null())
.unwrap_or_else(c_error)
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_save_state(
session: *mut play96_session,
path: *const c_char,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
let Ok(path) = (unsafe { required_string(path, "path") }) else {
return c_error("invalid path");
};
unsafe { session_result(session, (*session).session.save_state(path)) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_load_state(
session: *mut play96_session,
path: *const c_char,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
let Ok(path) = (unsafe { required_string(path, "path") }) else {
return c_error("invalid path");
};
unsafe { session_result(session, (*session).session.load_state(path)) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_write_png(
session: *const play96_session,
path: *const c_char,
) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
let Ok(path) = (unsafe { required_string(path, "path") }) else {
return c_error("invalid path");
};
unsafe {
(*session)
.session
.write_png(path)
.map(|_| ptr::null())
.unwrap_or_else(c_error)
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn play96_last_error(session: *const play96_session) -> *const c_char {
if session.is_null() {
return c_error("session must not be null");
}
unsafe { (*session).error.as_ptr() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_conversion_is_xrgb() {
assert_eq!(Color::rgb(0x12, 0x34, 0x56).xrgb(), 0x123456);
}
#[test]
fn hashes_are_stable() {
assert_eq!(fnv1a(&[1, 2, 3]), 0xa094014f35a4929d);
assert_eq!(fnv1a_u32(&[0x030201]), fnv1a(&0x030201_u32.to_ne_bytes()));
}
#[test]
fn pixel_assertion_reports_coordinates_and_colors() {
let capture = Capture {
pixels: vec![0x112233],
width: 1,
height: 1,
..Default::default()
};
let actual = Color::rgb(
(capture.pixels[0] >> 16) as u8,
(capture.pixels[0] >> 8) as u8,
capture.pixels[0] as u8,
);
let expected = Color::rgb(0, 0, 0);
let error = Error::new(format!(
"pixel (0, 0) expected #{:02x}{:02x}{:02x}{:02x}, got #{:02x}{:02x}{:02x}{:02x}",
expected.red,
expected.green,
expected.blue,
expected.alpha,
actual.red,
actual.green,
actual.blue,
actual.alpha
));
assert!(error.to_string().contains("pixel (0, 0)"));
assert!(error.to_string().contains("#000000ff"));
}
}