Skip to main content

play96/
lib.rs

1#![allow(clippy::missing_safety_doc)]
2
3use libloading::Library;
4use std::collections::HashSet;
5use std::ffi::{CStr, CString, c_char, c_void};
6use std::fs;
7use std::path::Path;
8use std::ptr;
9use std::sync::{Mutex, MutexGuard};
10
11const RETRO_API_VERSION: u32 = 1;
12const RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: u32 = 9;
13const RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: u32 = 10;
14const RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK: u32 = 21;
15const RETRO_ENVIRONMENT_GET_CONTENT_DIRECTORY: u32 = 30;
16const RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: u32 = 31;
17const RETRO_ENVIRONMENT_SET_GEOMETRY: u32 = 37;
18const RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK: u32 = 12;
19const RETRO_PIXEL_FORMAT_0RGB1555: u32 = 0;
20const RETRO_PIXEL_FORMAT_XRGB8888: u32 = 1;
21const RETRO_PIXEL_FORMAT_RGB565: u32 = 2;
22const RETRO_DEVICE_JOYPAD: u32 = 1;
23const RETRO_DEVICE_MOUSE: u32 = 2;
24const RETRO_DEVICE_KEYBOARD: u32 = 3;
25const RETRO_DEVICE_MASK: u32 = 0xff;
26const MAX_DIMENSION: usize = 2048;
27const BUTTON_COUNT: usize = 12;
28
29#[repr(C)]
30struct RetroGameInfo {
31    path: *const c_char,
32    data: *const c_void,
33    size: usize,
34    meta: *const c_char,
35}
36
37#[repr(C)]
38#[derive(Clone, Copy, Default)]
39struct RetroGameGeometry {
40    base_width: u32,
41    base_height: u32,
42    max_width: u32,
43    max_height: u32,
44    aspect_ratio: f32,
45}
46
47#[repr(C)]
48#[derive(Clone, Copy, Default)]
49struct RetroSystemTiming {
50    fps: f64,
51    sample_rate: f64,
52}
53
54#[repr(C)]
55#[derive(Clone, Copy, Default)]
56struct RetroSystemAvInfo {
57    geometry: RetroGameGeometry,
58    timing: RetroSystemTiming,
59}
60
61type EnvironmentCallback = unsafe extern "C" fn(u32, *mut c_void) -> bool;
62type VideoCallback = unsafe extern "C" fn(*const c_void, u32, u32, usize);
63type AudioCallback = unsafe extern "C" fn(i16, i16);
64type AudioBatchCallback = unsafe extern "C" fn(*const i16, usize) -> usize;
65type InputPollCallback = unsafe extern "C" fn();
66type InputStateCallback = unsafe extern "C" fn(u32, u32, u32, u32) -> i16;
67type KeyboardEventCallback = unsafe extern "C" fn(bool, u32, u32, u16);
68type FrameTimeCallback = unsafe extern "C" fn(i64);
69
70#[repr(C)]
71#[derive(Clone, Copy)]
72struct RetroFrameTimeCallback {
73    callback: Option<FrameTimeCallback>,
74    reference: i64,
75}
76
77#[repr(C)]
78#[derive(Clone, Copy)]
79struct RetroKeyboardCallback {
80    callback: Option<KeyboardEventCallback>,
81}
82
83type RetroApiVersion = unsafe extern "C" fn() -> u32;
84type RetroSetEnvironment = unsafe extern "C" fn(EnvironmentCallback);
85type RetroSetVideo = unsafe extern "C" fn(VideoCallback);
86type RetroSetAudio = unsafe extern "C" fn(AudioCallback);
87type RetroSetAudioBatch = unsafe extern "C" fn(AudioBatchCallback);
88type RetroSetInputPoll = unsafe extern "C" fn(InputPollCallback);
89type RetroSetInputState = unsafe extern "C" fn(InputStateCallback);
90type RetroInit = unsafe extern "C" fn();
91type RetroDeinit = unsafe extern "C" fn();
92type RetroGetSystemAvInfo = unsafe extern "C" fn(*mut RetroSystemAvInfo);
93type RetroSetControllerPortDevice = unsafe extern "C" fn(u32, u32);
94type RetroLoadGame = unsafe extern "C" fn(*const RetroGameInfo) -> bool;
95type RetroUnloadGame = unsafe extern "C" fn();
96type RetroRun = unsafe extern "C" fn();
97type RetroSerializeSize = unsafe extern "C" fn() -> usize;
98type RetroSerialize = unsafe extern "C" fn(*mut c_void, usize) -> bool;
99type RetroUnserialize = unsafe extern "C" fn(*const c_void, usize) -> bool;
100type RetroGetMemoryData = unsafe extern "C" fn(u32) -> *mut c_void;
101type RetroGetMemorySize = unsafe extern "C" fn(u32) -> usize;
102
103struct CoreApi {
104    api_version: RetroApiVersion,
105    set_environment: RetroSetEnvironment,
106    set_video: RetroSetVideo,
107    set_audio: RetroSetAudio,
108    set_audio_batch: RetroSetAudioBatch,
109    set_input_poll: RetroSetInputPoll,
110    set_input_state: RetroSetInputState,
111    init: RetroInit,
112    deinit: RetroDeinit,
113    av_info: RetroGetSystemAvInfo,
114    set_port_device: RetroSetControllerPortDevice,
115    load_game: RetroLoadGame,
116    unload_game: RetroUnloadGame,
117    run: RetroRun,
118    serialize_size: RetroSerializeSize,
119    serialize: RetroSerialize,
120    unserialize: RetroUnserialize,
121    memory_data: RetroGetMemoryData,
122    memory_size: RetroGetMemorySize,
123}
124
125struct Capture {
126    pixels: Vec<u32>,
127    audio: Vec<i16>,
128    width: u32,
129    height: u32,
130    pixel_format: u32,
131    av: RetroSystemAvInfo,
132    pad: [u8; BUTTON_COUNT],
133    keys: HashSet<u32>,
134    key_events: Vec<(bool, u32)>,
135    keyboard_callback: Option<KeyboardEventCallback>,
136    frame_time_callback: Option<FrameTimeCallback>,
137    frame_time_reference: i64,
138    mouse: [i16; 11],
139    system_directory: CString,
140    content_directory: CString,
141    save_directory: CString,
142}
143
144impl Default for Capture {
145    fn default() -> Self {
146        Self {
147            pixels: Vec::new(),
148            audio: Vec::new(),
149            width: 0,
150            height: 0,
151            pixel_format: RETRO_PIXEL_FORMAT_XRGB8888,
152            av: RetroSystemAvInfo::default(),
153            pad: [0; BUTTON_COUNT],
154            keys: HashSet::new(),
155            key_events: Vec::new(),
156            keyboard_callback: None,
157            frame_time_callback: None,
158            frame_time_reference: 0,
159            mouse: [0; 11],
160            system_directory: CString::new(".").unwrap(),
161            content_directory: CString::new(".").unwrap(),
162            save_directory: CString::new(".").unwrap(),
163        }
164    }
165}
166
167static mut ACTIVE_CAPTURE: *mut Capture = ptr::null_mut();
168static SESSION_LOCK: Mutex<()> = Mutex::new(());
169
170unsafe extern "C" fn environment_callback(command: u32, data: *mut c_void) -> bool {
171    // libretro callbacks run synchronously while a Session owns the active capture.
172    let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_mut() }) else {
173        return false;
174    };
175    match command {
176        RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY => {
177            if data.is_null() {
178                return false;
179            }
180            unsafe { *(data as *mut *const c_char) = capture.system_directory.as_ptr() };
181            true
182        }
183        RETRO_ENVIRONMENT_SET_PIXEL_FORMAT => {
184            if data.is_null() {
185                return false;
186            }
187            let pixel_format = unsafe { *(data as *const u32) };
188            if matches!(
189                pixel_format,
190                RETRO_PIXEL_FORMAT_0RGB1555
191                    | RETRO_PIXEL_FORMAT_XRGB8888
192                    | RETRO_PIXEL_FORMAT_RGB565
193            ) {
194                capture.pixel_format = pixel_format;
195                true
196            } else {
197                false
198            }
199        }
200        RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK => {
201            if data.is_null() {
202                return false;
203            }
204            let callback = unsafe { *(data as *const RetroFrameTimeCallback) };
205            capture.frame_time_callback = callback.callback;
206            capture.frame_time_reference = callback.reference;
207            true
208        }
209        RETRO_ENVIRONMENT_GET_CONTENT_DIRECTORY => {
210            if data.is_null() {
211                return false;
212            }
213            unsafe { *(data as *mut *const c_char) = capture.content_directory.as_ptr() };
214            true
215        }
216        RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY => {
217            if data.is_null() {
218                return false;
219            }
220            unsafe { *(data as *mut *const c_char) = capture.save_directory.as_ptr() };
221            true
222        }
223        RETRO_ENVIRONMENT_SET_GEOMETRY => {
224            if data.is_null() {
225                return false;
226            }
227            capture.av.geometry = unsafe { *(data as *const RetroGameGeometry) };
228            true
229        }
230        RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK => {
231            if data.is_null() {
232                return false;
233            }
234            capture.keyboard_callback = unsafe { *(data as *const RetroKeyboardCallback) }.callback;
235            true
236        }
237        _ => false,
238    }
239}
240
241fn xrgb_from_pixel(pixel_format: u32, pixel: u32) -> Option<u32> {
242    let expand_5 = |value: u32| (value << 3) | (value >> 2);
243    let expand_6 = |value: u32| (value << 2) | (value >> 4);
244    match pixel_format {
245        RETRO_PIXEL_FORMAT_XRGB8888 => Some(pixel & 0x00ff_ffff),
246        RETRO_PIXEL_FORMAT_RGB565 => Some(
247            (expand_5((pixel >> 11) & 0x1f) << 16)
248                | (expand_6((pixel >> 5) & 0x3f) << 8)
249                | expand_5(pixel & 0x1f),
250        ),
251        RETRO_PIXEL_FORMAT_0RGB1555 => Some(
252            (expand_5((pixel >> 10) & 0x1f) << 16)
253                | (expand_5((pixel >> 5) & 0x1f) << 8)
254                | expand_5(pixel & 0x1f),
255        ),
256        _ => None,
257    }
258}
259
260unsafe extern "C" fn video_callback(data: *const c_void, width: u32, height: u32, pitch: usize) {
261    let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_mut() }) else {
262        return;
263    };
264    let width = width as usize;
265    let height = height as usize;
266    let bytes_per_pixel = match capture.pixel_format {
267        RETRO_PIXEL_FORMAT_XRGB8888 => 4,
268        RETRO_PIXEL_FORMAT_0RGB1555 | RETRO_PIXEL_FORMAT_RGB565 => 2,
269        _ => return,
270    };
271    if width == 0
272        || height == 0
273        || width > MAX_DIMENSION
274        || height > MAX_DIMENSION
275        || pitch < width * bytes_per_pixel
276    {
277        return;
278    }
279    capture.width = width as u32;
280    capture.height = height as u32;
281    if data.is_null() {
282        return;
283    }
284    capture.pixels.resize(width * height, 0);
285    let bytes = data as *const u8;
286    for y in 0..height {
287        let row =
288            unsafe { std::slice::from_raw_parts(bytes.add(y * pitch), width * bytes_per_pixel) };
289        for x in 0..width {
290            let offset = x * bytes_per_pixel;
291            let pixel = match bytes_per_pixel {
292                2 => u32::from(u16::from_ne_bytes([row[offset], row[offset + 1]])),
293                4 => u32::from_ne_bytes([
294                    row[offset],
295                    row[offset + 1],
296                    row[offset + 2],
297                    row[offset + 3],
298                ]),
299                _ => unreachable!(),
300            };
301            capture.pixels[y * width + x] = xrgb_from_pixel(capture.pixel_format, pixel).unwrap();
302        }
303    }
304}
305
306unsafe extern "C" fn audio_callback(left: i16, right: i16) {
307    if let Some(capture) = unsafe { ACTIVE_CAPTURE.as_mut() } {
308        capture.audio.extend_from_slice(&[left, right]);
309    }
310}
311unsafe extern "C" fn audio_batch_callback(data: *const i16, frames: usize) -> usize {
312    if !data.is_null()
313        && let Some(capture) = unsafe { ACTIVE_CAPTURE.as_mut() }
314    {
315        let samples = unsafe { std::slice::from_raw_parts(data, frames.saturating_mul(2)) };
316        capture.audio.extend_from_slice(samples);
317    }
318    frames
319}
320unsafe extern "C" fn input_poll_callback() {
321    let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_mut() }) else {
322        return;
323    };
324    if let Some(callback) = capture.keyboard_callback {
325        for (pressed, keycode) in capture.key_events.drain(..) {
326            let character = if (32..=126).contains(&keycode) {
327                keycode
328            } else {
329                0
330            };
331            unsafe { callback(pressed, keycode, character, 0) };
332        }
333    } else {
334        capture.key_events.clear();
335    }
336}
337
338unsafe extern "C" fn input_state_callback(_: u32, device: u32, _: u32, id: u32) -> i16 {
339    let Some(capture) = (unsafe { ACTIVE_CAPTURE.as_ref() }) else {
340        return 0;
341    };
342    match device & RETRO_DEVICE_MASK {
343        RETRO_DEVICE_JOYPAD => {
344            const MAP: [u32; BUTTON_COUNT] = [4, 5, 6, 7, 8, 0, 9, 1, 10, 11, 3, 2];
345            MAP.iter()
346                .position(|&button| button == id)
347                .map(|index| i16::from(capture.pad[index] != 0))
348                .unwrap_or(0)
349        }
350        RETRO_DEVICE_KEYBOARD => i16::from(capture.keys.contains(&id)),
351        RETRO_DEVICE_MOUSE => capture.mouse.get(id as usize).copied().unwrap_or(0),
352        _ => 0,
353    }
354}
355
356unsafe fn symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T, Error> {
357    unsafe { library.get::<T>(name) }
358        .map(|symbol| *symbol)
359        .map_err(|error| {
360            Error::new(format!(
361                "missing libretro symbol {}: {error}",
362                String::from_utf8_lossy(&name[..name.len() - 1])
363            ))
364        })
365}
366
367unsafe fn load_api(library: &Library) -> Result<CoreApi, Error> {
368    Ok(CoreApi {
369        api_version: unsafe { symbol(library, b"retro_api_version\0")? },
370        set_environment: unsafe { symbol(library, b"retro_set_environment\0")? },
371        set_video: unsafe { symbol(library, b"retro_set_video_refresh\0")? },
372        set_audio: unsafe { symbol(library, b"retro_set_audio_sample\0")? },
373        set_audio_batch: unsafe { symbol(library, b"retro_set_audio_sample_batch\0")? },
374        set_input_poll: unsafe { symbol(library, b"retro_set_input_poll\0")? },
375        set_input_state: unsafe { symbol(library, b"retro_set_input_state\0")? },
376        init: unsafe { symbol(library, b"retro_init\0")? },
377        deinit: unsafe { symbol(library, b"retro_deinit\0")? },
378        av_info: unsafe { symbol(library, b"retro_get_system_av_info\0")? },
379        set_port_device: unsafe { symbol(library, b"retro_set_controller_port_device\0")? },
380        load_game: unsafe { symbol(library, b"retro_load_game\0")? },
381        unload_game: unsafe { symbol(library, b"retro_unload_game\0")? },
382        run: unsafe { symbol(library, b"retro_run\0")? },
383        serialize_size: unsafe { symbol(library, b"retro_serialize_size\0")? },
384        serialize: unsafe { symbol(library, b"retro_serialize\0")? },
385        unserialize: unsafe { symbol(library, b"retro_unserialize\0")? },
386        memory_data: unsafe { symbol(library, b"retro_get_memory_data\0")? },
387        memory_size: unsafe { symbol(library, b"retro_get_memory_size\0")? },
388    })
389}
390
391#[derive(Debug, Clone)]
392pub struct Error(String);
393
394impl Error {
395    pub fn new(message: impl Into<String>) -> Self {
396        Self(message.into())
397    }
398}
399
400impl std::fmt::Display for Error {
401    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        self.0.fmt(formatter)
403    }
404}
405
406impl std::error::Error for Error {}
407
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub struct Color {
410    pub red: u8,
411    pub green: u8,
412    pub blue: u8,
413    pub alpha: u8,
414}
415
416impl Color {
417    pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
418        Self {
419            red,
420            green,
421            blue,
422            alpha: 255,
423        }
424    }
425    pub const fn xrgb(self) -> u32 {
426        ((self.red as u32) << 16) | ((self.green as u32) << 8) | self.blue as u32
427    }
428}
429
430#[derive(Clone, Copy, Debug, Eq, PartialEq)]
431#[repr(usize)]
432pub enum Button {
433    Up = 0,
434    Down = 1,
435    Left = 2,
436    Right = 3,
437    A = 4,
438    B = 5,
439    X = 6,
440    Y = 7,
441    L1 = 8,
442    R1 = 9,
443    Start = 10,
444    Select = 11,
445}
446
447/// Common libretro keyboard key codes. Letter constants are lowercase because
448/// Shift is reported as a separate key.
449pub mod key {
450    pub const BACKSPACE: u32 = 8;
451    pub const TAB: u32 = 9;
452    pub const RETURN: u32 = 13;
453    pub const ESCAPE: u32 = 27;
454    pub const SPACE: u32 = 32;
455    pub const NUM_0: u32 = b'0' as u32;
456    pub const NUM_1: u32 = b'1' as u32;
457    pub const NUM_2: u32 = b'2' as u32;
458    pub const NUM_3: u32 = b'3' as u32;
459    pub const NUM_4: u32 = b'4' as u32;
460    pub const NUM_5: u32 = b'5' as u32;
461    pub const NUM_6: u32 = b'6' as u32;
462    pub const NUM_7: u32 = b'7' as u32;
463    pub const NUM_8: u32 = b'8' as u32;
464    pub const NUM_9: u32 = b'9' as u32;
465    pub const A: u32 = b'a' as u32;
466    pub const B: u32 = b'b' as u32;
467    pub const C: u32 = b'c' as u32;
468    pub const D: u32 = b'd' as u32;
469    pub const E: u32 = b'e' as u32;
470    pub const F: u32 = b'f' as u32;
471    pub const G: u32 = b'g' as u32;
472    pub const H: u32 = b'h' as u32;
473    pub const I: u32 = b'i' as u32;
474    pub const J: u32 = b'j' as u32;
475    pub const K: u32 = b'k' as u32;
476    pub const L: u32 = b'l' as u32;
477    pub const M: u32 = b'm' as u32;
478    pub const N: u32 = b'n' as u32;
479    pub const O: u32 = b'o' as u32;
480    pub const P: u32 = b'p' as u32;
481    pub const Q: u32 = b'q' as u32;
482    pub const R: u32 = b'r' as u32;
483    pub const S: u32 = b's' as u32;
484    pub const T: u32 = b't' as u32;
485    pub const U: u32 = b'u' as u32;
486    pub const V: u32 = b'v' as u32;
487    pub const W: u32 = b'w' as u32;
488    pub const X: u32 = b'x' as u32;
489    pub const Y: u32 = b'y' as u32;
490    pub const Z: u32 = b'z' as u32;
491    pub const DELETE: u32 = 127;
492    pub const UP: u32 = 273;
493    pub const DOWN: u32 = 274;
494    pub const RIGHT: u32 = 275;
495    pub const LEFT: u32 = 276;
496    pub const INSERT: u32 = 277;
497    pub const HOME: u32 = 278;
498    pub const END: u32 = 279;
499    pub const PAGE_UP: u32 = 280;
500    pub const PAGE_DOWN: u32 = 281;
501    pub const F1: u32 = 282;
502    pub const F2: u32 = 283;
503    pub const F3: u32 = 284;
504    pub const F4: u32 = 285;
505    pub const F5: u32 = 286;
506    pub const F6: u32 = 287;
507    pub const F7: u32 = 288;
508    pub const F8: u32 = 289;
509    pub const F9: u32 = 290;
510    pub const F10: u32 = 291;
511    pub const F11: u32 = 292;
512    pub const F12: u32 = 293;
513    pub const RIGHT_SHIFT: u32 = 303;
514    pub const LEFT_SHIFT: u32 = 304;
515    pub const RIGHT_CTRL: u32 = 305;
516    pub const LEFT_CTRL: u32 = 306;
517    pub const RIGHT_ALT: u32 = 307;
518    pub const LEFT_ALT: u32 = 308;
519}
520
521#[derive(Clone, Copy, Debug, Eq, PartialEq)]
522#[repr(usize)]
523pub enum MouseButton {
524    Left = 2,
525    Right = 3,
526    Middle = 6,
527    Button4 = 9,
528    Button5 = 10,
529}
530
531#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
532#[repr(u32)]
533pub enum InputDevice {
534    None = 0,
535    #[default]
536    Joypad = RETRO_DEVICE_JOYPAD,
537    Mouse = RETRO_DEVICE_MOUSE,
538    Keyboard = RETRO_DEVICE_KEYBOARD,
539}
540
541pub struct Session {
542    _session_guard: MutexGuard<'static, ()>,
543    library: Library,
544    api: CoreApi,
545    capture: Box<Capture>,
546    cartridge: Vec<u8>,
547    cartridge_path: CString,
548    loaded: bool,
549}
550
551impl Session {
552    pub fn new(
553        core_path: impl AsRef<Path>,
554        cartridge_path: impl AsRef<Path>,
555    ) -> Result<Self, Error> {
556        let session_guard = SESSION_LOCK.try_lock().map_err(|_| {
557            Error::new(
558                "another play96 session is active; libretro cores can only run one at a time",
559            )
560        })?;
561        let library = unsafe { Library::new(core_path.as_ref()) }
562            .map_err(|error| Error::new(format!("failed to load core: {error}")))?;
563        let api = unsafe { load_api(&library)? };
564        if unsafe { (api.api_version)() } != RETRO_API_VERSION {
565            return Err(Error::new("unsupported libretro API version"));
566        }
567        let cartridge = fs::read(cartridge_path.as_ref())
568            .map_err(|error| Error::new(format!("failed to read cartridge: {error}")))?;
569        let cartridge_c_path = CString::new(cartridge_path.as_ref().to_string_lossy().as_bytes())
570            .map_err(|_| Error::new("cartridge path contains a NUL byte"))?;
571        let directory_string = |path: &Path, name: &str| {
572            let directory = path
573                .parent()
574                .filter(|directory| !directory.as_os_str().is_empty())
575                .unwrap_or_else(|| Path::new("."));
576            CString::new(directory.to_string_lossy().as_bytes())
577                .map_err(|_| Error::new(format!("{name} directory contains a NUL byte")))
578        };
579        let system_directory = directory_string(core_path.as_ref(), "system")?;
580        let content_directory = directory_string(cartridge_path.as_ref(), "content")?;
581        let mut capture = Box::<Capture>::default();
582        capture.system_directory = system_directory;
583        capture.content_directory = content_directory.clone();
584        capture.save_directory = content_directory;
585        let mut session = Self {
586            _session_guard: session_guard,
587            library,
588            api,
589            capture,
590            cartridge,
591            cartridge_path: cartridge_c_path,
592            loaded: false,
593        };
594        session.activate();
595        unsafe {
596            (session.api.set_environment)(environment_callback);
597            (session.api.set_video)(video_callback);
598            (session.api.set_audio)(audio_callback);
599            (session.api.set_audio_batch)(audio_batch_callback);
600            (session.api.set_input_poll)(input_poll_callback);
601            (session.api.set_input_state)(input_state_callback);
602            (session.api.init)();
603            (session.api.set_port_device)(0, RETRO_DEVICE_JOYPAD);
604        }
605        let game = RetroGameInfo {
606            path: session.cartridge_path.as_ptr(),
607            data: session.cartridge.as_ptr().cast(),
608            size: session.cartridge.len(),
609            meta: ptr::null(),
610        };
611        if !unsafe { (session.api.load_game)(&game) } {
612            return Err(Error::new("failed to load cartridge"));
613        }
614        session.loaded = true;
615        unsafe { (session.api.av_info)(&mut session.capture.av) };
616        Ok(session)
617    }
618
619    fn activate(&mut self) {
620        unsafe { ACTIVE_CAPTURE = self.capture.as_mut() };
621    }
622
623    pub fn run_frame(&mut self) -> Result<(), Error> {
624        self.activate();
625        self.capture.audio.clear();
626        invoke_frame_time_callback(&self.capture);
627        unsafe { (self.api.run)() };
628        for id in [0, 1, 4, 5, 7, 8] {
629            self.capture.mouse[id] = 0;
630        }
631        Ok(())
632    }
633
634    pub fn run_frames(&mut self, frames: usize) -> Result<(), Error> {
635        for _ in 0..frames {
636            self.run_frame()?;
637        }
638        Ok(())
639    }
640
641    pub fn set_button(&mut self, button: Button, pressed: bool) {
642        self.capture.pad[button as usize] = u8::from(pressed);
643    }
644
645    pub fn set_controller_device(&mut self, port: u32, device: InputDevice) {
646        unsafe { (self.api.set_port_device)(port, device as u32) };
647    }
648
649    pub fn clear_buttons(&mut self) {
650        self.capture.pad = [0; BUTTON_COUNT];
651    }
652
653    pub fn set_key(&mut self, keycode: u32, pressed: bool) {
654        let changed = if pressed {
655            self.capture.keys.insert(keycode)
656        } else {
657            self.capture.keys.remove(&keycode)
658        };
659        if changed {
660            self.capture.key_events.push((pressed, keycode));
661        }
662    }
663
664    pub fn key_pressed(&self, keycode: u32) -> bool {
665        self.capture.keys.contains(&keycode)
666    }
667
668    pub fn clear_keys(&mut self) {
669        let mut keys = self.capture.keys.drain().collect::<Vec<_>>();
670        keys.sort_unstable();
671        self.capture
672            .key_events
673            .extend(keys.into_iter().map(|keycode| (false, keycode)));
674    }
675
676    /// Sets relative mouse movement for the next frame.
677    pub fn set_mouse_delta(&mut self, x: i16, y: i16) {
678        self.capture.mouse[0] = x;
679        self.capture.mouse[1] = y;
680    }
681
682    pub fn set_mouse_button(&mut self, button: MouseButton, pressed: bool) {
683        self.capture.mouse[button as usize] = i16::from(pressed);
684    }
685
686    pub fn clear_mouse_buttons(&mut self) {
687        for button in [
688            MouseButton::Left,
689            MouseButton::Right,
690            MouseButton::Middle,
691            MouseButton::Button4,
692            MouseButton::Button5,
693        ] {
694            self.capture.mouse[button as usize] = 0;
695        }
696    }
697
698    /// Sets vertical and horizontal wheel movement for the next frame.
699    pub fn set_mouse_wheel(&mut self, vertical: i16, horizontal: i16) {
700        (self.capture.mouse[4], self.capture.mouse[5]) = if vertical >= 0 {
701            (vertical, 0)
702        } else {
703            (0, vertical.saturating_abs())
704        };
705        (self.capture.mouse[7], self.capture.mouse[8]) = if horizontal >= 0 {
706            (horizontal, 0)
707        } else {
708            (0, horizontal.saturating_abs())
709        };
710    }
711    pub fn framebuffer_width(&self) -> u32 {
712        self.capture.width
713    }
714    pub fn framebuffer_height(&self) -> u32 {
715        self.capture.height
716    }
717
718    pub fn framebuffer(&self) -> &[u32] {
719        &self.capture.pixels
720    }
721
722    pub fn frame_hash(&self) -> u64 {
723        fnv1a_u32(&self.capture.pixels)
724    }
725
726    /// Interleaved signed 16-bit stereo samples produced by the last frame.
727    pub fn audio_samples(&self) -> &[i16] {
728        &self.capture.audio
729    }
730
731    pub fn audio_frame_count(&self) -> usize {
732        self.capture.audio.len() / 2
733    }
734
735    pub fn audio_sample_rate(&self) -> f64 {
736        self.capture.av.timing.sample_rate
737    }
738
739    pub fn audio_frame(&self, frame: usize) -> Result<[i16; 2], Error> {
740        let offset = frame.saturating_mul(2);
741        if offset + 1 >= self.capture.audio.len() {
742            return Err(Error::new(format!(
743                "audio frame {frame} is outside {} captured frames",
744                self.audio_frame_count()
745            )));
746        }
747        Ok([self.capture.audio[offset], self.capture.audio[offset + 1]])
748    }
749
750    pub fn audio_hash(&self) -> u64 {
751        fnv1a_i16(&self.capture.audio)
752    }
753
754    pub fn assert_audio_sample(
755        &self,
756        frame: usize,
757        channel: usize,
758        expected: i16,
759        tolerance: u16,
760    ) -> Result<(), Error> {
761        if channel >= 2 {
762            return Err(Error::new(format!(
763                "audio channel {channel} is outside stereo channels 0 and 1"
764            )));
765        }
766        let actual = self.audio_frame(frame)?[channel];
767        if (i32::from(actual) - i32::from(expected)).unsigned_abs() <= u32::from(tolerance) {
768            Ok(())
769        } else {
770            Err(Error::new(format!(
771                "audio frame {frame} channel {channel} expected {expected} +/- {tolerance}, got {actual}"
772            )))
773        }
774    }
775
776    pub fn assert_audio_hash(&self, expected: u64) -> Result<(), Error> {
777        let actual = self.audio_hash();
778        if actual == expected {
779            Ok(())
780        } else {
781            Err(Error::new(format!(
782                "audio hash expected {expected:016x}, got {actual:016x}"
783            )))
784        }
785    }
786
787    pub fn save_ram_hash(&mut self) -> u64 {
788        self.activate();
789        const RETRO_MEMORY_SAVE_RAM: u32 = 0;
790        let size = unsafe { (self.api.memory_size)(RETRO_MEMORY_SAVE_RAM) };
791        let data = unsafe { (self.api.memory_data)(RETRO_MEMORY_SAVE_RAM) };
792        if size == 0 || data.is_null() {
793            return 0;
794        }
795        let bytes = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), size) };
796        fnv1a(bytes)
797    }
798
799    pub fn pixel_xrgb(&self, x: u32, y: u32) -> Result<u32, Error> {
800        if x >= self.capture.width || y >= self.capture.height {
801            return Err(Error::new(format!(
802                "pixel ({x}, {y}) is outside {}x{} framebuffer",
803                self.capture.width, self.capture.height
804            )));
805        }
806        Ok(self.capture.pixels[y as usize * self.capture.width as usize + x as usize])
807    }
808
809    pub fn pixel(&self, x: u32, y: u32) -> Result<Color, Error> {
810        let value = self.pixel_xrgb(x, y)?;
811        Ok(Color::rgb(
812            (value >> 16) as u8,
813            (value >> 8) as u8,
814            value as u8,
815        ))
816    }
817
818    pub fn assert_pixel(&self, x: u32, y: u32, expected: Color) -> Result<(), Error> {
819        let actual = self.pixel(x, y)?;
820        if actual == expected {
821            Ok(())
822        } else {
823            Err(Error::new(format!(
824                "pixel ({x}, {y}) expected #{:02x}{:02x}{:02x}{:02x}, got #{:02x}{:02x}{:02x}{:02x}",
825                expected.red,
826                expected.green,
827                expected.blue,
828                expected.alpha,
829                actual.red,
830                actual.green,
831                actual.blue,
832                actual.alpha
833            )))
834        }
835    }
836
837    pub fn assert_pixel_xrgb(&self, x: u32, y: u32, expected: u32) -> Result<(), Error> {
838        let actual = self.pixel_xrgb(x, y)?;
839        if actual == expected {
840            Ok(())
841        } else {
842            Err(Error::new(format!(
843                "pixel ({x}, {y}) expected #{expected:06x}, got #{actual:06x}"
844            )))
845        }
846    }
847
848    pub fn save_state(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
849        self.activate();
850        let size = unsafe { (self.api.serialize_size)() };
851        if size == 0 {
852            return Err(Error::new("core reported an empty save state"));
853        }
854        let mut state = vec![0; size];
855        if !unsafe { (self.api.serialize)(state.as_mut_ptr().cast(), state.len()) } {
856            return Err(Error::new("failed to save state"));
857        }
858        fs::write(path, state)
859            .map_err(|error| Error::new(format!("failed to write state: {error}")))
860    }
861
862    pub fn load_state(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
863        self.activate();
864        let state =
865            fs::read(path).map_err(|error| Error::new(format!("failed to read state: {error}")))?;
866        if unsafe { (self.api.unserialize)(state.as_ptr().cast(), state.len()) } {
867            Ok(())
868        } else {
869            Err(Error::new("failed to load state"))
870        }
871    }
872
873    pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
874        if self.capture.pixels.is_empty() {
875            return Err(Error::new("no framebuffer has been received"));
876        }
877        let file = fs::File::create(path)
878            .map_err(|error| Error::new(format!("failed to create PNG: {error}")))?;
879        let mut encoder = png::Encoder::new(file, self.capture.width, self.capture.height);
880        encoder.set_color(png::ColorType::Rgba);
881        encoder.set_depth(png::BitDepth::Eight);
882        let mut writer = encoder
883            .write_header()
884            .map_err(|error| Error::new(format!("failed to write PNG header: {error}")))?;
885        let mut rgba = Vec::with_capacity(self.capture.pixels.len() * 4);
886        for pixel in &self.capture.pixels {
887            rgba.extend_from_slice(&[(pixel >> 16) as u8, (pixel >> 8) as u8, *pixel as u8, 255]);
888        }
889        writer
890            .write_image_data(&rgba)
891            .map_err(|error| Error::new(format!("failed to write PNG: {error}")))
892    }
893}
894
895fn invoke_frame_time_callback(capture: &Capture) {
896    if let Some(callback) = capture.frame_time_callback {
897        unsafe { callback(capture.frame_time_reference) };
898    }
899}
900
901fn fnv1a(bytes: &[u8]) -> u64 {
902    bytes.iter().fold(1_469_598_103_934_665_603, |hash, byte| {
903        (hash ^ u64::from(*byte)).wrapping_mul(1_099_511_628_211)
904    })
905}
906
907fn fnv1a_u32(values: &[u32]) -> u64 {
908    let mut hash = 1_469_598_103_934_665_603_u64;
909    for value in values {
910        for byte in value.to_ne_bytes() {
911            hash = (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211);
912        }
913    }
914    hash
915}
916
917fn fnv1a_i16(values: &[i16]) -> u64 {
918    let mut hash = 1_469_598_103_934_665_603_u64;
919    for value in values {
920        for byte in value.to_le_bytes() {
921            hash = (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211);
922        }
923    }
924    hash
925}
926
927impl Drop for Session {
928    fn drop(&mut self) {
929        self.activate();
930        unsafe {
931            if self.loaded {
932                (self.api.unload_game)();
933            }
934            (self.api.deinit)();
935            ACTIVE_CAPTURE = ptr::null_mut();
936        }
937        let _ = &self.library;
938    }
939}
940
941#[repr(C)]
942pub struct Play96Color {
943    pub red: u8,
944    pub green: u8,
945    pub blue: u8,
946    pub alpha: u8,
947}
948
949#[repr(C)]
950pub struct play96_session {
951    session: Session,
952    error: CString,
953}
954
955fn c_error(error: impl std::fmt::Display) -> *const c_char {
956    thread_local! { static ERROR: std::cell::RefCell<CString> = std::cell::RefCell::new(CString::new("unknown error").unwrap()); }
957    ERROR.with(|slot| {
958        *slot.borrow_mut() = CString::new(error.to_string())
959            .unwrap_or_else(|_| CString::new("error contained a NUL byte").unwrap());
960        slot.borrow().as_ptr()
961    })
962}
963
964unsafe fn required_string<'a>(value: *const c_char, name: &str) -> Result<&'a str, *const c_char> {
965    if value.is_null() {
966        return Err(c_error(format!("{name} must not be null")));
967    }
968    unsafe { CStr::from_ptr(value) }
969        .to_str()
970        .map_err(|_| c_error(format!("{name} is not valid UTF-8")))
971}
972
973fn session_result(session: *mut play96_session, result: Result<(), Error>) -> *const c_char {
974    match result {
975        Ok(()) => ptr::null(),
976        Err(error) => unsafe {
977            if session.is_null() {
978                c_error(error)
979            } else {
980                (*session).error = CString::new(error.to_string()).unwrap();
981                (*session).error.as_ptr()
982            }
983        },
984    }
985}
986
987#[unsafe(no_mangle)]
988pub unsafe extern "C" fn play96_create(
989    core: *const c_char,
990    cartridge: *const c_char,
991    out: *mut *mut play96_session,
992) -> *const c_char {
993    if out.is_null() {
994        return c_error("out_session must not be null");
995    }
996    let Ok(core) = (unsafe { required_string(core, "core_path") }) else {
997        return c_error("invalid core_path");
998    };
999    let Ok(cartridge) = (unsafe { required_string(cartridge, "cartridge_path") }) else {
1000        return c_error("invalid cartridge_path");
1001    };
1002    match Session::new(core, cartridge) {
1003        Ok(session) => {
1004            unsafe {
1005                *out = Box::into_raw(Box::new(play96_session {
1006                    session,
1007                    error: CString::new("").unwrap(),
1008                }));
1009            }
1010            ptr::null()
1011        }
1012        Err(error) => c_error(error),
1013    }
1014}
1015
1016#[unsafe(no_mangle)]
1017pub unsafe extern "C" fn play96_destroy(session: *mut play96_session) {
1018    if !session.is_null() {
1019        unsafe {
1020            drop(Box::from_raw(session));
1021        }
1022    }
1023}
1024
1025#[unsafe(no_mangle)]
1026pub unsafe extern "C" fn play96_run_frame(session: *mut play96_session) -> *const c_char {
1027    if session.is_null() {
1028        return c_error("session must not be null");
1029    }
1030    unsafe { session_result(session, (*session).session.run_frame()) }
1031}
1032
1033#[unsafe(no_mangle)]
1034pub unsafe extern "C" fn play96_run_frames(
1035    session: *mut play96_session,
1036    frames: usize,
1037) -> *const c_char {
1038    if session.is_null() {
1039        return c_error("session must not be null");
1040    }
1041    unsafe { session_result(session, (*session).session.run_frames(frames)) }
1042}
1043
1044#[unsafe(no_mangle)]
1045pub unsafe extern "C" fn play96_set_button(
1046    session: *mut play96_session,
1047    port: u32,
1048    button: u32,
1049    pressed: i32,
1050) -> *const c_char {
1051    if session.is_null() {
1052        return c_error("session must not be null");
1053    }
1054    if port != 0 || button as usize >= BUTTON_COUNT {
1055        return c_error("only port 0 and buttons 0 through 11 are supported");
1056    }
1057    unsafe {
1058        (*session).session.capture.pad[button as usize] = u8::from(pressed != 0);
1059    }
1060    ptr::null()
1061}
1062
1063#[unsafe(no_mangle)]
1064pub unsafe extern "C" fn play96_clear_buttons(
1065    session: *mut play96_session,
1066    port: u32,
1067) -> *const c_char {
1068    if session.is_null() {
1069        return c_error("session must not be null");
1070    }
1071    if port != 0 {
1072        return c_error("only port 0 is supported");
1073    }
1074    unsafe {
1075        (*session).session.clear_buttons();
1076    }
1077    ptr::null()
1078}
1079
1080#[unsafe(no_mangle)]
1081pub unsafe extern "C" fn play96_set_controller_device(
1082    session: *mut play96_session,
1083    port: u32,
1084    device: u32,
1085) -> *const c_char {
1086    if session.is_null() {
1087        return c_error("session must not be null");
1088    }
1089    let device = match device {
1090        0 => InputDevice::None,
1091        1 => InputDevice::Joypad,
1092        2 => InputDevice::Mouse,
1093        3 => InputDevice::Keyboard,
1094        _ => return c_error("input device must be 0, 1, 2, or 3"),
1095    };
1096    unsafe { (*session).session.set_controller_device(port, device) };
1097    ptr::null()
1098}
1099
1100#[unsafe(no_mangle)]
1101pub unsafe extern "C" fn play96_set_key(
1102    session: *mut play96_session,
1103    keycode: u32,
1104    pressed: i32,
1105) -> *const c_char {
1106    if session.is_null() {
1107        return c_error("session must not be null");
1108    }
1109    unsafe { (*session).session.set_key(keycode, pressed != 0) };
1110    ptr::null()
1111}
1112
1113#[unsafe(no_mangle)]
1114pub unsafe extern "C" fn play96_clear_keys(session: *mut play96_session) -> *const c_char {
1115    if session.is_null() {
1116        return c_error("session must not be null");
1117    }
1118    unsafe { (*session).session.clear_keys() };
1119    ptr::null()
1120}
1121
1122#[unsafe(no_mangle)]
1123pub unsafe extern "C" fn play96_set_mouse_delta(
1124    session: *mut play96_session,
1125    x: i16,
1126    y: i16,
1127) -> *const c_char {
1128    if session.is_null() {
1129        return c_error("session must not be null");
1130    }
1131    unsafe { (*session).session.set_mouse_delta(x, y) };
1132    ptr::null()
1133}
1134
1135#[unsafe(no_mangle)]
1136pub unsafe extern "C" fn play96_set_mouse_button(
1137    session: *mut play96_session,
1138    button: usize,
1139    pressed: i32,
1140) -> *const c_char {
1141    if session.is_null() {
1142        return c_error("session must not be null");
1143    }
1144    let button = match button {
1145        2 => MouseButton::Left,
1146        3 => MouseButton::Right,
1147        6 => MouseButton::Middle,
1148        9 => MouseButton::Button4,
1149        10 => MouseButton::Button5,
1150        _ => return c_error("mouse button must be 2, 3, 6, 9, or 10"),
1151    };
1152    unsafe { (*session).session.set_mouse_button(button, pressed != 0) };
1153    ptr::null()
1154}
1155
1156#[unsafe(no_mangle)]
1157pub unsafe extern "C" fn play96_clear_mouse_buttons(session: *mut play96_session) -> *const c_char {
1158    if session.is_null() {
1159        return c_error("session must not be null");
1160    }
1161    unsafe { (*session).session.clear_mouse_buttons() };
1162    ptr::null()
1163}
1164
1165#[unsafe(no_mangle)]
1166pub unsafe extern "C" fn play96_set_mouse_wheel(
1167    session: *mut play96_session,
1168    vertical: i16,
1169    horizontal: i16,
1170) -> *const c_char {
1171    if session.is_null() {
1172        return c_error("session must not be null");
1173    }
1174    unsafe { (*session).session.set_mouse_wheel(vertical, horizontal) };
1175    ptr::null()
1176}
1177
1178#[unsafe(no_mangle)]
1179pub unsafe extern "C" fn play96_framebuffer_width(session: *const play96_session) -> u32 {
1180    if session.is_null() {
1181        0
1182    } else {
1183        unsafe { (*session).session.framebuffer_width() }
1184    }
1185}
1186
1187#[unsafe(no_mangle)]
1188pub unsafe extern "C" fn play96_framebuffer_height(session: *const play96_session) -> u32 {
1189    if session.is_null() {
1190        0
1191    } else {
1192        unsafe { (*session).session.framebuffer_height() }
1193    }
1194}
1195
1196#[unsafe(no_mangle)]
1197pub unsafe extern "C" fn play96_pixel_xrgb(session: *const play96_session, x: u32, y: u32) -> u32 {
1198    if session.is_null() {
1199        return 0;
1200    }
1201    unsafe { (*session).session.pixel_xrgb(x, y).unwrap_or(0) }
1202}
1203
1204#[unsafe(no_mangle)]
1205pub unsafe extern "C" fn play96_assert_pixel(
1206    session: *const play96_session,
1207    x: u32,
1208    y: u32,
1209    expected: Play96Color,
1210) -> *const c_char {
1211    if session.is_null() {
1212        return c_error("session must not be null");
1213    }
1214    let expected = Color {
1215        red: expected.red,
1216        green: expected.green,
1217        blue: expected.blue,
1218        alpha: expected.alpha,
1219    };
1220    unsafe {
1221        (*session)
1222            .session
1223            .assert_pixel(x, y, expected)
1224            .map(|_| ptr::null())
1225            .unwrap_or_else(c_error)
1226    }
1227}
1228
1229#[unsafe(no_mangle)]
1230pub unsafe extern "C" fn play96_assert_pixel_xrgb(
1231    session: *const play96_session,
1232    x: u32,
1233    y: u32,
1234    expected: u32,
1235) -> *const c_char {
1236    if session.is_null() {
1237        return c_error("session must not be null");
1238    }
1239    unsafe {
1240        (*session)
1241            .session
1242            .assert_pixel_xrgb(x, y, expected)
1243            .map(|_| ptr::null())
1244            .unwrap_or_else(c_error)
1245    }
1246}
1247
1248#[unsafe(no_mangle)]
1249pub unsafe extern "C" fn play96_audio_samples(session: *const play96_session) -> *const i16 {
1250    if session.is_null() {
1251        return ptr::null();
1252    }
1253    let samples = unsafe { (*session).session.audio_samples() };
1254    if samples.is_empty() {
1255        ptr::null()
1256    } else {
1257        samples.as_ptr()
1258    }
1259}
1260
1261#[unsafe(no_mangle)]
1262pub unsafe extern "C" fn play96_audio_sample_count(session: *const play96_session) -> usize {
1263    if session.is_null() {
1264        return 0;
1265    }
1266    unsafe { (*session).session.audio_samples().len() }
1267}
1268
1269#[unsafe(no_mangle)]
1270pub unsafe extern "C" fn play96_audio_frame_count(session: *const play96_session) -> usize {
1271    if session.is_null() {
1272        return 0;
1273    }
1274    unsafe { (*session).session.audio_frame_count() }
1275}
1276
1277#[unsafe(no_mangle)]
1278pub unsafe extern "C" fn play96_audio_sample_rate(session: *const play96_session) -> f64 {
1279    if session.is_null() {
1280        return 0.0;
1281    }
1282    unsafe { (*session).session.audio_sample_rate() }
1283}
1284
1285#[unsafe(no_mangle)]
1286pub unsafe extern "C" fn play96_audio_hash(session: *const play96_session) -> u64 {
1287    if session.is_null() {
1288        return 0;
1289    }
1290    unsafe { (*session).session.audio_hash() }
1291}
1292
1293#[unsafe(no_mangle)]
1294pub unsafe extern "C" fn play96_assert_audio_sample(
1295    session: *const play96_session,
1296    frame: usize,
1297    channel: usize,
1298    expected: i16,
1299    tolerance: u16,
1300) -> *const c_char {
1301    if session.is_null() {
1302        return c_error("session must not be null");
1303    }
1304    unsafe {
1305        (*session)
1306            .session
1307            .assert_audio_sample(frame, channel, expected, tolerance)
1308            .map(|_| ptr::null())
1309            .unwrap_or_else(c_error)
1310    }
1311}
1312
1313#[unsafe(no_mangle)]
1314pub unsafe extern "C" fn play96_assert_audio_hash(
1315    session: *const play96_session,
1316    expected: u64,
1317) -> *const c_char {
1318    if session.is_null() {
1319        return c_error("session must not be null");
1320    }
1321    unsafe {
1322        (*session)
1323            .session
1324            .assert_audio_hash(expected)
1325            .map(|_| ptr::null())
1326            .unwrap_or_else(c_error)
1327    }
1328}
1329
1330#[unsafe(no_mangle)]
1331pub unsafe extern "C" fn play96_save_state(
1332    session: *mut play96_session,
1333    path: *const c_char,
1334) -> *const c_char {
1335    if session.is_null() {
1336        return c_error("session must not be null");
1337    }
1338    let Ok(path) = (unsafe { required_string(path, "path") }) else {
1339        return c_error("invalid path");
1340    };
1341    unsafe { session_result(session, (*session).session.save_state(path)) }
1342}
1343
1344#[unsafe(no_mangle)]
1345pub unsafe extern "C" fn play96_load_state(
1346    session: *mut play96_session,
1347    path: *const c_char,
1348) -> *const c_char {
1349    if session.is_null() {
1350        return c_error("session must not be null");
1351    }
1352    let Ok(path) = (unsafe { required_string(path, "path") }) else {
1353        return c_error("invalid path");
1354    };
1355    unsafe { session_result(session, (*session).session.load_state(path)) }
1356}
1357
1358#[unsafe(no_mangle)]
1359pub unsafe extern "C" fn play96_write_png(
1360    session: *const play96_session,
1361    path: *const c_char,
1362) -> *const c_char {
1363    if session.is_null() {
1364        return c_error("session must not be null");
1365    }
1366    let Ok(path) = (unsafe { required_string(path, "path") }) else {
1367        return c_error("invalid path");
1368    };
1369    unsafe {
1370        (*session)
1371            .session
1372            .write_png(path)
1373            .map(|_| ptr::null())
1374            .unwrap_or_else(c_error)
1375    }
1376}
1377
1378#[unsafe(no_mangle)]
1379pub unsafe extern "C" fn play96_last_error(session: *const play96_session) -> *const c_char {
1380    if session.is_null() {
1381        return c_error("session must not be null");
1382    }
1383    unsafe { (*session).error.as_ptr() }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1390
1391    static KEY_EVENT_PRESSED: AtomicBool = AtomicBool::new(false);
1392    static KEY_EVENT_CODE: AtomicU32 = AtomicU32::new(0);
1393    static FRAME_TIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1394
1395    unsafe extern "C" fn record_frame_time(reference: i64) {
1396        FRAME_TIME.store(reference, Ordering::Relaxed);
1397    }
1398
1399    unsafe extern "C" fn record_key_event(pressed: bool, keycode: u32, _: u32, _: u16) {
1400        KEY_EVENT_PRESSED.store(pressed, Ordering::Relaxed);
1401        KEY_EVENT_CODE.store(keycode, Ordering::Relaxed);
1402    }
1403
1404    #[test]
1405    fn color_conversion_is_xrgb() {
1406        assert_eq!(Color::rgb(0x12, 0x34, 0x56).xrgb(), 0x123456);
1407    }
1408
1409    #[test]
1410    fn pixel_formats_expand_to_xrgb() {
1411        assert_eq!(
1412            xrgb_from_pixel(RETRO_PIXEL_FORMAT_XRGB8888, 0xaa123456),
1413            Some(0x123456)
1414        );
1415        assert_eq!(
1416            xrgb_from_pixel(RETRO_PIXEL_FORMAT_RGB565, 0xf800),
1417            Some(0xff0000)
1418        );
1419        assert_eq!(
1420            xrgb_from_pixel(RETRO_PIXEL_FORMAT_RGB565, 0x07e0),
1421            Some(0x00ff00)
1422        );
1423        assert_eq!(
1424            xrgb_from_pixel(RETRO_PIXEL_FORMAT_RGB565, 0x001f),
1425            Some(0x0000ff)
1426        );
1427        assert_eq!(
1428            xrgb_from_pixel(RETRO_PIXEL_FORMAT_0RGB1555, 0xfc00),
1429            Some(0xff0000)
1430        );
1431        assert_eq!(
1432            xrgb_from_pixel(RETRO_PIXEL_FORMAT_0RGB1555, 0x83e0),
1433            Some(0x00ff00)
1434        );
1435        assert_eq!(
1436            xrgb_from_pixel(RETRO_PIXEL_FORMAT_0RGB1555, 0x801f),
1437            Some(0x0000ff)
1438        );
1439        assert_eq!(xrgb_from_pixel(99, 0), None);
1440    }
1441
1442    #[test]
1443    fn video_callback_accepts_padded_16_bit_rows() {
1444        let _guard = SESSION_LOCK.lock().unwrap();
1445        let mut capture = Capture {
1446            pixel_format: RETRO_PIXEL_FORMAT_RGB565,
1447            ..Default::default()
1448        };
1449        let pixels = [0u8, 0xf8, 0, 0, 0xe0, 0x07, 0, 0];
1450        unsafe { ACTIVE_CAPTURE = &mut capture };
1451        unsafe { video_callback(pixels.as_ptr().cast(), 1, 2, 4) };
1452        unsafe { ACTIVE_CAPTURE = ptr::null_mut() };
1453        assert_eq!(capture.width, 1);
1454        assert_eq!(capture.height, 2);
1455        assert_eq!(capture.pixels, [0xff0000, 0x00ff00]);
1456    }
1457
1458    #[test]
1459    fn hashes_are_stable() {
1460        assert_eq!(fnv1a(&[1, 2, 3]), 0xa094014f35a4929d);
1461        assert_eq!(fnv1a_u32(&[0x030201]), fnv1a(&0x030201_u32.to_ne_bytes()));
1462        assert_eq!(fnv1a_i16(&[0x0201, 0x0403]), fnv1a(&[1, 2, 3, 4]));
1463    }
1464
1465    #[test]
1466    fn pixel_assertion_reports_coordinates_and_colors() {
1467        let capture = Capture {
1468            pixels: vec![0x112233],
1469            width: 1,
1470            height: 1,
1471            ..Default::default()
1472        };
1473        let actual = Color::rgb(
1474            (capture.pixels[0] >> 16) as u8,
1475            (capture.pixels[0] >> 8) as u8,
1476            capture.pixels[0] as u8,
1477        );
1478        let expected = Color::rgb(0, 0, 0);
1479        let error = Error::new(format!(
1480            "pixel (0, 0) expected #{:02x}{:02x}{:02x}{:02x}, got #{:02x}{:02x}{:02x}{:02x}",
1481            expected.red,
1482            expected.green,
1483            expected.blue,
1484            expected.alpha,
1485            actual.red,
1486            actual.green,
1487            actual.blue,
1488            actual.alpha
1489        ));
1490        assert!(error.to_string().contains("pixel (0, 0)"));
1491        assert!(error.to_string().contains("#000000ff"));
1492    }
1493
1494    #[test]
1495    fn frame_time_callback_is_registered_and_invoked_with_its_reference() {
1496        let _guard = SESSION_LOCK.lock().unwrap();
1497        let mut capture = Capture::default();
1498        let callback = RetroFrameTimeCallback {
1499            callback: Some(record_frame_time),
1500            reference: 16_667,
1501        };
1502        unsafe { ACTIVE_CAPTURE = &mut capture };
1503        assert!(unsafe {
1504            environment_callback(
1505                RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK,
1506                (&raw const callback).cast_mut().cast(),
1507            )
1508        });
1509        invoke_frame_time_callback(&capture);
1510        assert_eq!(FRAME_TIME.load(Ordering::Relaxed), 16_667);
1511        unsafe { ACTIVE_CAPTURE = ptr::null_mut() };
1512    }
1513
1514    #[test]
1515    fn directory_callbacks_return_session_owned_paths() {
1516        let _guard = SESSION_LOCK.lock().unwrap();
1517        let mut capture = Capture {
1518            system_directory: CString::new("system").unwrap(),
1519            content_directory: CString::new("content").unwrap(),
1520            save_directory: CString::new("save").unwrap(),
1521            ..Default::default()
1522        };
1523        unsafe { ACTIVE_CAPTURE = &mut capture };
1524        for (command, expected) in [
1525            (RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY, "system"),
1526            (RETRO_ENVIRONMENT_GET_CONTENT_DIRECTORY, "content"),
1527            (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY, "save"),
1528        ] {
1529            let mut directory: *const c_char = ptr::null();
1530            assert!(unsafe { environment_callback(command, (&raw mut directory).cast()) });
1531            assert_eq!(
1532                unsafe { CStr::from_ptr(directory) }.to_str().unwrap(),
1533                expected
1534            );
1535        }
1536        unsafe { ACTIVE_CAPTURE = ptr::null_mut() };
1537    }
1538
1539    #[test]
1540    fn pixel_format_negotiation_accepts_libretro_formats() {
1541        let _guard = SESSION_LOCK.lock().unwrap();
1542        let mut capture = Capture::default();
1543        unsafe { ACTIVE_CAPTURE = &mut capture };
1544        for pixel_format in [
1545            RETRO_PIXEL_FORMAT_0RGB1555,
1546            RETRO_PIXEL_FORMAT_XRGB8888,
1547            RETRO_PIXEL_FORMAT_RGB565,
1548        ] {
1549            assert!(unsafe {
1550                environment_callback(
1551                    RETRO_ENVIRONMENT_SET_PIXEL_FORMAT,
1552                    (&raw const pixel_format).cast_mut().cast(),
1553                )
1554            });
1555            assert_eq!(capture.pixel_format, pixel_format);
1556        }
1557        let invalid = 99_u32;
1558        assert!(!unsafe {
1559            environment_callback(
1560                RETRO_ENVIRONMENT_SET_PIXEL_FORMAT,
1561                (&raw const invalid).cast_mut().cast(),
1562            )
1563        });
1564        assert_eq!(capture.pixel_format, RETRO_PIXEL_FORMAT_RGB565);
1565        unsafe { ACTIVE_CAPTURE = ptr::null_mut() };
1566    }
1567
1568    #[test]
1569    fn keyboard_and_mouse_callbacks_report_input() {
1570        let _guard = SESSION_LOCK.lock().unwrap();
1571        let mut capture = Capture::default();
1572        capture.keys.insert(key::SPACE);
1573        capture.key_events.push((true, key::SPACE));
1574        capture.keyboard_callback = Some(record_key_event);
1575        capture.mouse[0] = 7;
1576        capture.mouse[1] = -3;
1577        capture.mouse[MouseButton::Left as usize] = 1;
1578        unsafe { ACTIVE_CAPTURE = &mut capture };
1579
1580        assert_eq!(
1581            unsafe { input_state_callback(0, RETRO_DEVICE_KEYBOARD, 0, key::SPACE) },
1582            1
1583        );
1584        assert_eq!(
1585            unsafe { input_state_callback(0, RETRO_DEVICE_MOUSE, 0, 0) },
1586            7
1587        );
1588        assert_eq!(
1589            unsafe { input_state_callback(0, RETRO_DEVICE_MOUSE, 0, 1) },
1590            -3
1591        );
1592        assert_eq!(
1593            unsafe { input_state_callback(0, RETRO_DEVICE_MOUSE, 0, MouseButton::Left as u32) },
1594            1
1595        );
1596        unsafe { input_poll_callback() };
1597        assert!(KEY_EVENT_PRESSED.load(Ordering::Relaxed));
1598        assert_eq!(KEY_EVENT_CODE.load(Ordering::Relaxed), key::SPACE);
1599
1600        unsafe { ACTIVE_CAPTURE = ptr::null_mut() };
1601    }
1602}