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 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
447pub 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 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 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 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}