2020-02-14 12:01:48 +00:00
|
|
|
use sdl2;
|
2020-05-12 08:29:49 +01:00
|
|
|
use sdl2::controller::Button;
|
2020-05-12 05:32:59 +01:00
|
|
|
use sdl2::event::{Event, WindowEvent};
|
2020-05-12 08:29:49 +01:00
|
|
|
use sdl2::image::{InitFlag, LoadSurface, LoadTexture};
|
2020-05-03 21:03:57 +01:00
|
|
|
use sdl2::keyboard::Scancode;
|
2020-01-11 23:29:36 +00:00
|
|
|
use sdl2::pixels::Color;
|
2020-05-01 15:56:26 +01:00
|
|
|
use sdl2::rect::Rect;
|
2020-05-01 16:13:00 +01:00
|
|
|
use sdl2::render::WindowCanvas;
|
2020-05-12 08:29:49 +01:00
|
|
|
use sdl2::surface::Surface;
|
2020-02-14 12:01:48 +00:00
|
|
|
|
2020-01-11 15:28:25 +00:00
|
|
|
use sdl2::EventPump;
|
2019-12-09 21:37:46 +00:00
|
|
|
|
2020-02-14 12:01:48 +00:00
|
|
|
use bytesize;
|
|
|
|
use spin_sleep;
|
2019-12-20 17:02:33 +00:00
|
|
|
|
2019-12-09 21:37:46 +00:00
|
|
|
use std::cell::RefCell;
|
|
|
|
use std::rc::Rc;
|
2019-12-04 23:15:49 +00:00
|
|
|
|
2020-01-31 00:12:38 +00:00
|
|
|
use std::fs;
|
2020-05-20 23:23:11 +01:00
|
|
|
use std::io::Cursor;
|
2020-01-16 18:07:14 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
2020-01-11 15:28:25 +00:00
|
|
|
use std::process;
|
2020-01-31 00:12:38 +00:00
|
|
|
use std::time;
|
2020-01-11 15:28:25 +00:00
|
|
|
|
2020-01-31 12:47:27 +00:00
|
|
|
use std::convert::TryFrom;
|
|
|
|
|
2019-12-04 23:15:49 +00:00
|
|
|
#[macro_use]
|
|
|
|
extern crate clap;
|
|
|
|
|
2020-01-30 23:47:52 +00:00
|
|
|
#[macro_use]
|
|
|
|
extern crate log;
|
2020-02-14 12:01:48 +00:00
|
|
|
use flexi_logger;
|
2020-01-30 23:47:52 +00:00
|
|
|
use flexi_logger::*;
|
|
|
|
|
2019-12-04 23:15:49 +00:00
|
|
|
mod audio;
|
2019-12-20 16:11:14 +00:00
|
|
|
mod input;
|
2019-12-04 23:15:49 +00:00
|
|
|
mod video;
|
|
|
|
|
2019-12-28 13:59:35 +00:00
|
|
|
use audio::create_audio_player;
|
|
|
|
use input::create_input;
|
2020-01-20 23:11:50 +00:00
|
|
|
use video::{create_video_interface, SCREEN_HEIGHT, SCREEN_WIDTH};
|
2019-12-04 23:15:49 +00:00
|
|
|
|
2020-05-01 16:13:00 +01:00
|
|
|
use rustboyadvance_core::cartridge::BackupType;
|
2020-04-11 14:06:34 +01:00
|
|
|
use rustboyadvance_core::prelude::*;
|
|
|
|
use rustboyadvance_core::util::spawn_and_run_gdb_server;
|
|
|
|
use rustboyadvance_core::util::FpsCounter;
|
2019-12-04 23:15:49 +00:00
|
|
|
|
2020-01-30 23:47:52 +00:00
|
|
|
const LOG_DIR: &str = ".logs";
|
2020-02-22 22:19:55 +00:00
|
|
|
const DEFAULT_GDB_SERVER_ADDR: &'static str = "localhost:1337";
|
2020-01-30 23:47:52 +00:00
|
|
|
|
2020-05-01 15:56:26 +01:00
|
|
|
const CANVAS_WIDTH: u32 = SCREEN_WIDTH;
|
|
|
|
const CANVAS_HEIGHT: u32 = SCREEN_HEIGHT;
|
|
|
|
|
2020-01-16 18:07:14 +00:00
|
|
|
fn get_savestate_path(rom_filename: &Path) -> PathBuf {
|
|
|
|
rom_filename.with_extension("savestate")
|
|
|
|
}
|
|
|
|
|
2020-01-11 15:28:25 +00:00
|
|
|
/// Waits for the user to drag a rom file to window
|
2020-05-01 15:56:26 +01:00
|
|
|
fn wait_for_rom(canvas: &mut WindowCanvas, event_pump: &mut EventPump) -> Result<String, String> {
|
|
|
|
let texture_creator = canvas.texture_creator();
|
|
|
|
let icon_texture = texture_creator
|
|
|
|
.load_texture("assets/icon_cropped_small.png")
|
|
|
|
.expect("failed to load icon");
|
|
|
|
let background = Color::RGB(0xDD, 0xDD, 0xDD);
|
2020-05-12 05:32:59 +01:00
|
|
|
|
|
|
|
let mut redraw = || -> Result<(), String> {
|
2020-05-01 15:56:26 +01:00
|
|
|
canvas.set_draw_color(background);
|
|
|
|
canvas.clear();
|
|
|
|
canvas.copy(
|
|
|
|
&icon_texture,
|
|
|
|
None,
|
|
|
|
Some(Rect::from_center(
|
|
|
|
((CANVAS_WIDTH / 2) as i32, (CANVAS_HEIGHT / 2) as i32),
|
|
|
|
160,
|
|
|
|
100,
|
|
|
|
)),
|
|
|
|
)?;
|
|
|
|
canvas.present();
|
2020-05-12 05:32:59 +01:00
|
|
|
Ok(())
|
|
|
|
};
|
|
|
|
|
|
|
|
redraw()?;
|
|
|
|
|
|
|
|
loop {
|
|
|
|
for event in event_pump.wait_iter() {
|
|
|
|
match event {
|
|
|
|
Event::DropFile { filename, .. } => {
|
|
|
|
return Ok(filename);
|
|
|
|
}
|
|
|
|
Event::Quit { .. } => process::exit(0),
|
|
|
|
Event::Window { win_event, .. } => match win_event {
|
2020-05-12 08:27:33 +01:00
|
|
|
WindowEvent::SizeChanged(..) | WindowEvent::Restored => redraw()?,
|
2020-05-12 05:32:59 +01:00
|
|
|
_ => {}
|
|
|
|
},
|
|
|
|
_ => {}
|
|
|
|
}
|
|
|
|
}
|
2020-05-01 15:56:26 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-20 23:23:11 +01:00
|
|
|
fn ask_download_bios() {
|
2020-05-01 15:56:26 +01:00
|
|
|
const OPEN_SOURCE_BIOS_URL: &'static str =
|
|
|
|
"https://github.com/Nebuleon/ReGBA/raw/master/bios/gba_bios.bin";
|
2020-05-20 23:23:11 +01:00
|
|
|
println!("Missing BIOS file. If you don't have the original GBA BIOS, you can download an open-source bios from {}", OPEN_SOURCE_BIOS_URL);
|
2020-01-11 15:28:25 +00:00
|
|
|
}
|
|
|
|
|
2020-01-16 18:07:14 +00:00
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
2020-02-14 12:26:45 +00:00
|
|
|
fs::create_dir_all(LOG_DIR).expect(&format!("could not create log directory ({})", LOG_DIR));
|
2020-01-31 00:12:38 +00:00
|
|
|
flexi_logger::Logger::with_env_or_str("info")
|
2020-01-30 23:47:52 +00:00
|
|
|
.log_to_file()
|
|
|
|
.directory(LOG_DIR)
|
|
|
|
.duplicate_to_stderr(Duplicate::Debug)
|
|
|
|
.format_for_files(default_format)
|
|
|
|
.format_for_stderr(colored_default_format)
|
|
|
|
.start()
|
|
|
|
.unwrap();
|
|
|
|
|
2019-12-20 16:11:14 +00:00
|
|
|
let mut frame_limiter = true;
|
2019-12-04 23:15:49 +00:00
|
|
|
let yaml = load_yaml!("cli.yml");
|
|
|
|
let matches = clap::App::from_yaml(yaml).get_matches();
|
|
|
|
|
2020-05-20 23:23:11 +01:00
|
|
|
let bios_path = Path::new(matches.value_of("bios").unwrap_or_default());
|
|
|
|
let bios_bin = match read_bin_file(bios_path) {
|
2020-10-10 19:06:11 +01:00
|
|
|
Ok(bios) => bios.into_boxed_slice(),
|
2020-05-20 23:23:11 +01:00
|
|
|
_ => {
|
|
|
|
ask_download_bios();
|
|
|
|
std::process::exit(0);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-12-04 23:15:49 +00:00
|
|
|
let skip_bios = matches.occurrences_of("skip_bios") != 0;
|
2019-12-29 21:37:23 +00:00
|
|
|
|
2019-12-04 23:15:49 +00:00
|
|
|
let debug = matches.occurrences_of("debug") != 0;
|
2020-02-21 12:04:39 +00:00
|
|
|
let with_gdbserver = matches.occurrences_of("with_gdbserver") != 0;
|
2019-12-04 23:15:49 +00:00
|
|
|
|
2020-01-30 23:47:52 +00:00
|
|
|
info!("Initializing SDL2 context");
|
2020-01-11 23:29:36 +00:00
|
|
|
let sdl_context = sdl2::init().expect("failed to initialize sdl2");
|
2020-05-01 15:56:26 +01:00
|
|
|
|
2020-01-20 23:11:50 +00:00
|
|
|
let mut event_pump = sdl_context.event_pump()?;
|
2020-01-11 15:28:25 +00:00
|
|
|
|
2020-01-20 23:11:50 +00:00
|
|
|
let video_subsystem = sdl_context.video()?;
|
|
|
|
let _image_context = sdl2::image::init(InitFlag::PNG | InitFlag::JPG)?;
|
2020-05-12 08:29:49 +01:00
|
|
|
let mut window = video_subsystem
|
2020-01-20 23:11:50 +00:00
|
|
|
.window("RustBoyAdvance", SCREEN_WIDTH * 3, SCREEN_HEIGHT * 3)
|
2019-12-29 21:38:51 +00:00
|
|
|
.opengl()
|
|
|
|
.position_centered()
|
2020-01-20 23:11:50 +00:00
|
|
|
.resizable()
|
|
|
|
.build()?;
|
|
|
|
|
2020-05-12 08:29:49 +01:00
|
|
|
let window_icon = Surface::from_file("assets/icon.png")?;
|
|
|
|
window.set_icon(window_icon);
|
|
|
|
|
|
|
|
let mut canvas = window.into_canvas().accelerated().build()?;
|
2020-05-01 15:56:26 +01:00
|
|
|
canvas.set_logical_size(CANVAS_WIDTH, CANVAS_HEIGHT)?;
|
2020-01-11 15:28:25 +00:00
|
|
|
|
2020-05-09 22:51:40 +01:00
|
|
|
let controller_subsystem = sdl_context.game_controller()?;
|
2020-05-11 18:18:42 +01:00
|
|
|
let controller_mappings =
|
|
|
|
include_str!("../../../external/SDL_GameControllerDB/gamecontrollerdb.txt");
|
2020-05-09 22:51:40 +01:00
|
|
|
controller_subsystem.load_mappings_from_read(&mut Cursor::new(controller_mappings))?;
|
|
|
|
|
|
|
|
let available_controllers = (0..controller_subsystem.num_joysticks()?)
|
|
|
|
.filter(|&id| controller_subsystem.is_game_controller(id))
|
|
|
|
.collect::<Vec<u32>>();
|
|
|
|
|
2020-05-23 09:45:25 +01:00
|
|
|
let mut active_controller = match available_controllers.first() {
|
2020-05-09 22:51:40 +01:00
|
|
|
Some(&id) => {
|
|
|
|
let controller = controller_subsystem.open(id)?;
|
|
|
|
info!("Found game controller: {}", controller.name());
|
|
|
|
Some(controller)
|
2020-05-11 18:18:42 +01:00
|
|
|
}
|
2020-05-09 22:51:40 +01:00
|
|
|
_ => {
|
|
|
|
info!("No game controllers were found");
|
|
|
|
None
|
2020-05-11 18:18:42 +01:00
|
|
|
}
|
2020-05-09 22:51:40 +01:00
|
|
|
};
|
|
|
|
|
2020-01-11 15:28:25 +00:00
|
|
|
let mut rom_path = match matches.value_of("game_rom") {
|
|
|
|
Some(path) => path.to_string(),
|
|
|
|
_ => {
|
2020-01-30 23:47:52 +00:00
|
|
|
info!("[!] Rom file missing, please drag a rom file into the emulator window...");
|
2020-05-01 15:56:26 +01:00
|
|
|
wait_for_rom(&mut canvas, &mut event_pump)?
|
2020-01-11 15:28:25 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-05-01 15:56:26 +01:00
|
|
|
let video = Rc::new(RefCell::new(create_video_interface(canvas)));
|
|
|
|
let audio = Rc::new(RefCell::new(create_audio_player(&sdl_context)));
|
|
|
|
let input = Rc::new(RefCell::new(create_input()));
|
|
|
|
|
2020-01-16 18:07:14 +00:00
|
|
|
let mut savestate_path = get_savestate_path(&Path::new(&rom_path));
|
|
|
|
|
2020-01-11 15:28:25 +00:00
|
|
|
let mut rom_name = Path::new(&rom_path).file_name().unwrap().to_str().unwrap();
|
2020-01-31 12:47:27 +00:00
|
|
|
|
2020-05-17 18:12:31 +01:00
|
|
|
let mut builder = GamepakBuilder::new()
|
2020-01-31 12:47:27 +00:00
|
|
|
.save_type(BackupType::try_from(
|
|
|
|
matches.value_of("save_type").unwrap(),
|
|
|
|
)?)
|
2020-05-17 18:12:31 +01:00
|
|
|
.file(Path::new(&rom_path));
|
|
|
|
|
|
|
|
if matches.occurrences_of("rtc") != 0 {
|
|
|
|
builder = builder.with_rtc();
|
|
|
|
}
|
|
|
|
|
|
|
|
let gamepak = builder.build()?;
|
2020-01-11 15:28:25 +00:00
|
|
|
|
2019-12-22 23:27:25 +00:00
|
|
|
let mut gba = GameBoyAdvance::new(
|
2020-10-10 19:06:11 +01:00
|
|
|
bios_bin.clone(),
|
2020-01-31 12:24:41 +00:00
|
|
|
gamepak,
|
2019-12-20 16:11:14 +00:00
|
|
|
video.clone(),
|
|
|
|
audio.clone(),
|
|
|
|
input.clone(),
|
|
|
|
);
|
|
|
|
|
2020-01-16 17:47:05 +00:00
|
|
|
if skip_bios {
|
|
|
|
gba.skip_bios();
|
|
|
|
}
|
|
|
|
|
2019-12-04 23:15:49 +00:00
|
|
|
if debug {
|
2020-01-12 23:11:58 +00:00
|
|
|
#[cfg(feature = "debugger")]
|
2019-12-29 21:37:23 +00:00
|
|
|
{
|
|
|
|
gba.cpu.set_verbose(true);
|
|
|
|
let mut debugger = Debugger::new(gba);
|
2020-01-30 23:47:52 +00:00
|
|
|
info!("starting debugger...");
|
2019-12-29 21:37:23 +00:00
|
|
|
debugger.repl(matches.value_of("script_file")).unwrap();
|
2020-01-30 23:47:52 +00:00
|
|
|
info!("ending debugger...");
|
2020-02-10 20:08:28 +00:00
|
|
|
return Ok(());
|
2019-12-29 21:37:23 +00:00
|
|
|
}
|
2020-01-12 23:11:58 +00:00
|
|
|
#[cfg(not(feature = "debugger"))]
|
2019-12-29 21:37:23 +00:00
|
|
|
{
|
2020-01-12 23:11:58 +00:00
|
|
|
panic!("Please compile me with 'debugger' feature");
|
2019-12-29 21:37:23 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-21 12:04:39 +00:00
|
|
|
if with_gdbserver {
|
2020-02-22 22:19:55 +00:00
|
|
|
spawn_and_run_gdb_server(&mut gba, DEFAULT_GDB_SERVER_ADDR)?;
|
2020-02-21 12:04:39 +00:00
|
|
|
}
|
|
|
|
|
2020-01-11 15:28:25 +00:00
|
|
|
let mut fps_counter = FpsCounter::default();
|
2019-12-29 21:37:23 +00:00
|
|
|
let frame_time = time::Duration::new(0, 1_000_000_000u32 / 60);
|
2020-01-11 15:28:25 +00:00
|
|
|
'running: loop {
|
2019-12-29 21:37:23 +00:00
|
|
|
let start_time = time::Instant::now();
|
|
|
|
|
|
|
|
for event in event_pump.poll_iter() {
|
|
|
|
match event {
|
|
|
|
Event::KeyDown {
|
2020-05-03 21:03:57 +01:00
|
|
|
scancode: Some(scancode),
|
2019-12-29 21:37:23 +00:00
|
|
|
..
|
2020-05-03 21:03:57 +01:00
|
|
|
} => match scancode {
|
|
|
|
Scancode::Space => frame_limiter = false,
|
2020-05-01 16:36:41 +01:00
|
|
|
k => input.borrow_mut().on_keyboard_key_down(k),
|
|
|
|
},
|
2019-12-29 21:37:23 +00:00
|
|
|
Event::KeyUp {
|
2020-05-03 21:03:57 +01:00
|
|
|
scancode: Some(scancode),
|
2019-12-29 21:37:23 +00:00
|
|
|
..
|
2020-05-03 21:03:57 +01:00
|
|
|
} => match scancode {
|
2020-05-01 16:36:41 +01:00
|
|
|
#[cfg(feature = "debugger")]
|
2020-05-03 21:03:57 +01:00
|
|
|
Scancode::F1 => {
|
2020-05-01 16:36:41 +01:00
|
|
|
let mut debugger = Debugger::new(gba);
|
|
|
|
info!("starting debugger...");
|
|
|
|
debugger.repl(matches.value_of("script_file")).unwrap();
|
|
|
|
gba = debugger.gba;
|
|
|
|
info!("ending debugger...")
|
|
|
|
}
|
|
|
|
#[cfg(feature = "gdb")]
|
2020-05-03 21:03:57 +01:00
|
|
|
Scancode::F2 => spawn_and_run_gdb_server(&mut gba, DEFAULT_GDB_SERVER_ADDR)?,
|
|
|
|
Scancode::F5 => {
|
2020-05-01 16:36:41 +01:00
|
|
|
info!("Saving state ...");
|
|
|
|
let save = gba.save_state()?;
|
|
|
|
write_bin_file(&savestate_path, &save)?;
|
|
|
|
info!(
|
|
|
|
"Saved to {:?} ({})",
|
|
|
|
savestate_path,
|
|
|
|
bytesize::ByteSize::b(save.len() as u64)
|
|
|
|
);
|
|
|
|
}
|
2020-05-03 21:03:57 +01:00
|
|
|
Scancode::F9 => {
|
2020-05-01 16:36:41 +01:00
|
|
|
if savestate_path.is_file() {
|
|
|
|
let save = read_bin_file(&savestate_path)?;
|
|
|
|
info!("Restoring state from {:?}...", savestate_path);
|
2020-10-10 19:06:11 +01:00
|
|
|
gba.restore_state(&save, bios_bin.clone())?;
|
2020-05-01 16:36:41 +01:00
|
|
|
info!("Restored!");
|
|
|
|
} else {
|
|
|
|
info!("Savestate not created, please create one by pressing F5");
|
|
|
|
}
|
|
|
|
}
|
2020-05-03 21:03:57 +01:00
|
|
|
Scancode::Space => frame_limiter = true,
|
2020-05-01 16:36:41 +01:00
|
|
|
k => input.borrow_mut().on_keyboard_key_up(k),
|
|
|
|
},
|
2020-05-12 06:07:37 +01:00
|
|
|
Event::ControllerButtonDown { button, .. } => match button {
|
|
|
|
Button::RightStick => frame_limiter = !frame_limiter,
|
|
|
|
b => input.borrow_mut().on_controller_button_down(b),
|
|
|
|
},
|
2020-05-09 22:51:40 +01:00
|
|
|
Event::ControllerButtonUp { button, .. } => {
|
|
|
|
input.borrow_mut().on_controller_button_up(button);
|
2020-05-11 18:18:42 +01:00
|
|
|
}
|
|
|
|
Event::ControllerAxisMotion { axis, value, .. } => {
|
|
|
|
input.borrow_mut().on_axis_motion(axis, value);
|
|
|
|
}
|
2020-05-23 09:45:25 +01:00
|
|
|
Event::ControllerDeviceRemoved { which, .. } => {
|
|
|
|
let removed = if let Some(active_controller) = &active_controller {
|
|
|
|
active_controller.instance_id() == (which as i32)
|
|
|
|
} else {
|
|
|
|
false
|
|
|
|
};
|
|
|
|
if removed {
|
|
|
|
let name = active_controller
|
|
|
|
.and_then(|controller| Some(controller.name()))
|
|
|
|
.unwrap();
|
|
|
|
info!("Removing game controller: {}", name);
|
|
|
|
active_controller = None;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Event::ControllerDeviceAdded { which, .. } => {
|
|
|
|
if active_controller.is_none() {
|
|
|
|
let controller = controller_subsystem.open(which)?;
|
|
|
|
info!("Adding game controller: {}", controller.name());
|
|
|
|
active_controller = Some(controller);
|
|
|
|
}
|
|
|
|
}
|
2020-01-11 15:28:25 +00:00
|
|
|
Event::Quit { .. } => break 'running,
|
|
|
|
Event::DropFile { filename, .. } => {
|
|
|
|
// load the new rom
|
|
|
|
rom_path = filename;
|
2020-01-16 18:07:14 +00:00
|
|
|
savestate_path = get_savestate_path(&Path::new(&rom_path));
|
2020-01-11 15:28:25 +00:00
|
|
|
rom_name = Path::new(&rom_path).file_name().unwrap().to_str().unwrap();
|
2020-01-31 12:24:41 +00:00
|
|
|
let gamepak = GamepakBuilder::new().file(Path::new(&rom_path)).build()?;
|
2020-01-11 15:28:25 +00:00
|
|
|
let bios_bin = read_bin_file(bios_path).unwrap();
|
|
|
|
|
|
|
|
// create a new emulator - TODO, export to a function
|
|
|
|
gba = GameBoyAdvance::new(
|
2020-02-24 22:11:10 +00:00
|
|
|
bios_bin.into_boxed_slice(),
|
2020-01-31 12:24:41 +00:00
|
|
|
gamepak,
|
2020-01-11 15:28:25 +00:00
|
|
|
video.clone(),
|
|
|
|
audio.clone(),
|
|
|
|
input.clone(),
|
|
|
|
);
|
2020-01-16 17:47:05 +00:00
|
|
|
gba.skip_bios();
|
2020-01-11 15:28:25 +00:00
|
|
|
}
|
2019-12-29 21:37:23 +00:00
|
|
|
_ => {}
|
2019-12-20 16:11:14 +00:00
|
|
|
}
|
2019-12-29 21:37:23 +00:00
|
|
|
}
|
2019-12-20 16:11:14 +00:00
|
|
|
|
2019-12-29 21:37:23 +00:00
|
|
|
gba.frame();
|
2019-12-09 21:37:46 +00:00
|
|
|
|
2019-12-29 21:37:23 +00:00
|
|
|
if let Some(fps) = fps_counter.tick() {
|
|
|
|
let title = format!("{} ({} fps)", rom_name, fps);
|
|
|
|
video.borrow_mut().set_window_title(&title);
|
|
|
|
}
|
2019-12-04 23:15:49 +00:00
|
|
|
|
2019-12-29 21:37:23 +00:00
|
|
|
if frame_limiter {
|
|
|
|
let time_passed = start_time.elapsed();
|
|
|
|
let delay = frame_time.checked_sub(time_passed);
|
|
|
|
match delay {
|
|
|
|
None => {}
|
|
|
|
Some(delay) => {
|
|
|
|
spin_sleep::sleep(delay);
|
|
|
|
}
|
|
|
|
};
|
2019-12-04 23:15:49 +00:00
|
|
|
}
|
|
|
|
}
|
2020-01-16 18:07:14 +00:00
|
|
|
|
|
|
|
Ok(())
|
2019-12-04 23:15:49 +00:00
|
|
|
}
|