diff --git a/Cargo.lock b/Cargo.lock index 39fa9af..224fd04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,9 @@ name = "deranged" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +dependencies = [ + "serde", +] [[package]] name = "dolphin-integrations" @@ -1182,6 +1185,7 @@ dependencies = [ "open", "serde", "serde_json", + "time", "tracing", "ureq", ] @@ -1291,10 +1295,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ "deranged", + "itoa", "libc", "num_threads", "serde", "time-core", + "time-macros", ] [[package]] @@ -1303,6 +1309,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 9198c24..20249ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ debug = true panic = "abort" [workspace.dependencies] -time = { version = "0.3.20", default-features = false, features = ["std", "local-offset"] } +time = { version = "0.3.20", default-features = false, features = ["formatting", "parsing", "local-offset", "macros", "serde", "std"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_repr = { version = "0.1" } diff --git a/exi/src/config.rs b/exi/src/config.rs index cf486a1..eb34fbf 100644 --- a/exi/src/config.rs +++ b/exi/src/config.rs @@ -2,7 +2,7 @@ #[derive(Debug)] pub struct FilePathsConfig { pub iso: String, - pub user_json: String, + pub user_config_folder: String, } /// Source control semver related parameters. diff --git a/exi/src/lib.rs b/exi/src/lib.rs index 5f1885c..5331c1a 100644 --- a/exi/src/lib.rs +++ b/exi/src/lib.rs @@ -52,7 +52,7 @@ impl SlippiEXIDevice { let user_manager = UserManager::new( http_client.clone(), - config.paths.user_json.clone().into(), + config.paths.user_config_folder.clone().into(), config.scm.slippi_semver.clone(), ); diff --git a/ffi/build.rs b/ffi/build.rs index 6861699..0a5902f 100644 --- a/ffi/build.rs +++ b/ffi/build.rs @@ -14,8 +14,14 @@ fn main() { ..Default::default() }; + let warning = "/* Warning: this file is autogenerated by cbindgen. Don't modify this manually. */"; + let config = cbindgen::Config { + autogen_warning: Some(warning.into()), + cpp_compat: true, enumeration: enum_config, + language: cbindgen::Language::C, + pragma_once: true, ..Default::default() }; diff --git a/ffi/includes/SlippiRustExtensions.h b/ffi/includes/SlippiRustExtensions.h index 6e50fb8..870fff0 100644 --- a/ffi/includes/SlippiRustExtensions.h +++ b/ffi/includes/SlippiRustExtensions.h @@ -1,123 +1,166 @@ -#include -#include -#include -#include -#include - -/// This enum is duplicated from `slippi_game_reporter::OnlinePlayMode` in order -/// to appease cbindgen, which cannot see the type from the other module for -/// inspection. -/// -/// This enum will likely go away as things move towards Rust, since it's effectively -/// just C FFI glue code. -enum SlippiMatchmakingOnlinePlayMode { +#pragma once + +/* Warning: this file is autogenerated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include + +/** + * Indicates what type of direct code operation we're in. + */ +typedef enum DirectCodeKind { + DirectCodes = 1, + TeamsCodes = 2, +} DirectCodeKind; + +/** + * This enum is duplicated from `slippi_game_reporter::OnlinePlayMode` in order + * to appease cbindgen, which cannot see the type from the other module for + * inspection. + * + * This enum will likely go away as things move towards Rust, since it's effectively + * just C FFI glue code. + */ +typedef enum SlippiMatchmakingOnlinePlayMode { Ranked = 0, Unranked = 1, Direct = 2, Teams = 3, -}; - -/// A configuration struct for passing over certain argument types from the C/C++ side. -/// -/// The number of arguments necessary to shuttle across the FFI boundary when starting the -/// EXI device is higher than ideal at the moment, though it should lessen with time. For now, -/// this struct exists to act as a slightly more sane approach to readability of the args -/// structure. -struct SlippiRustEXIConfig { +} SlippiMatchmakingOnlinePlayMode; + +/** + * A configuration struct for passing over certain argument types from the C/C++ side. + * + * The number of arguments necessary to shuttle across the FFI boundary when starting the + * EXI device is higher than ideal at the moment, though it should lessen with time. For now, + * this struct exists to act as a slightly more sane approach to readability of the args + * structure. + */ +typedef struct SlippiRustEXIConfig { const char *iso_path; - const char *user_json_path; + const char *user_config_folder; const char *scm_slippi_semver_str; void (*osd_add_msg_fn)(const char*, uint32_t, uint32_t); -}; - -/// An intermediary type for moving `UserInfo` across the FFI boundary. -/// -/// This type is C compatible, and we coerce Rust types into C types for this struct to -/// ease passing things over. This must be free'd on the Rust side via `slprs_user_free_info`. -struct RustUserInfo { +} SlippiRustEXIConfig; + +/** + * An intermediary type for moving `UserInfo` across the FFI boundary. + * + * This type is C compatible, and we coerce Rust types into C types for this struct to + * ease passing things over. This must be free'd on the Rust side via `slprs_user_free_info`. + */ +typedef struct RustUserInfo { const char *uid; const char *play_key; const char *display_name; const char *connect_code; const char *latest_version; -}; - -/// An intermediary type for moving chat messages across the FFI boundary. -/// -/// This type is C compatible, and we coerce Rust types into C types for this struct to -/// ease passing things over. This must be free'd on the Rust side via `slprs_user_free_messages`. -struct RustChatMessages { +} RustUserInfo; + +/** + * An intermediary type for moving chat messages across the FFI boundary. + * + * This type is C compatible, and we coerce Rust types into C types for this struct to + * ease passing things over. This must be free'd on the Rust side via `slprs_user_free_messages`. + */ +typedef struct RustChatMessages { char **data; int len; -}; +} RustChatMessages; +#ifdef __cplusplus extern "C" { - -/// Creates and leaks a shadow EXI device with the provided configuration. -/// -/// The C++ (Dolphin) side of things should call this and pass the appropriate arguments. At -/// that point, everything on the Rust side is its own universe, and should be told to shut -/// down (at whatever point) via the corresponding `slprs_exi_device_destroy` function. -/// -/// The returned pointer from this should *not* be used after calling `slprs_exi_device_destroy`. -uintptr_t slprs_exi_device_create(SlippiRustEXIConfig config); - -/// The C++ (Dolphin) side of things should call this to notify the Rust side that it -/// can safely shut down and clean up. +#endif // __cplusplus + +/** + * Creates and leaks a shadow EXI device with the provided configuration. + * + * The C++ (Dolphin) side of things should call this and pass the appropriate arguments. At + * that point, everything on the Rust side is its own universe, and should be told to shut + * down (at whatever point) via the corresponding `slprs_exi_device_destroy` function. + * + * The returned pointer from this should *not* be used after calling `slprs_exi_device_destroy`. + */ +uintptr_t slprs_exi_device_create(struct SlippiRustEXIConfig config); + +/** + * The C++ (Dolphin) side of things should call this to notify the Rust side that it + * can safely shut down and clean up. + */ void slprs_exi_device_destroy(uintptr_t exi_device_instance_ptr); -/// This method should be called from the EXI device subclass shim that's registered on -/// the Dolphin side, corresponding to: -/// -/// `virtual void DMAWrite(u32 _uAddr, u32 _uSize);` +/** + * This method should be called from the EXI device subclass shim that's registered on + * the Dolphin side, corresponding to: + * + * `virtual void DMAWrite(u32 _uAddr, u32 _uSize);` + */ void slprs_exi_device_dma_write(uintptr_t exi_device_instance_ptr, const uint8_t *address, const uint8_t *size); -/// This method should be called from the EXI device subclass shim that's registered on -/// the Dolphin side, corresponding to: -/// -/// `virtual void DMARead(u32 _uAddr, u32 _uSize);` +/** + * This method should be called from the EXI device subclass shim that's registered on + * the Dolphin side, corresponding to: + * + * `virtual void DMARead(u32 _uAddr, u32 _uSize);` + */ void slprs_exi_device_dma_read(uintptr_t exi_device_instance_ptr, const uint8_t *address, const uint8_t *size); -/// Moves ownership of the `GameReport` at the specified address to the -/// `SlippiGameReporter` on the EXI Device the corresponding address. This -/// will then add it to the processing pipeline. -/// -/// The reporter will manage the actual... reporting. +/** + * Moves ownership of the `GameReport` at the specified address to the + * `SlippiGameReporter` on the EXI Device the corresponding address. This + * will then add it to the processing pipeline. + * + * The reporter will manage the actual... reporting. + */ void slprs_exi_device_log_game_report(uintptr_t instance_ptr, uintptr_t game_report_instance_ptr); -/// Calls through to `SlippiGameReporter::start_new_session`. +/** + * Calls through to `SlippiGameReporter::start_new_session`. + */ void slprs_exi_device_start_new_reporter_session(uintptr_t instance_ptr); -/// Calls through to the `SlippiGameReporter` on the EXI device to report a -/// match completion event. +/** + * Calls through to the `SlippiGameReporter` on the EXI device to report a + * match completion event. + */ void slprs_exi_device_report_match_completion(uintptr_t instance_ptr, const char *match_id, uint8_t end_mode); -/// Calls through to the `SlippiGameReporter` on the EXI device to report a -/// match abandon event. +/** + * Calls through to the `SlippiGameReporter` on the EXI device to report a + * match abandon event. + */ void slprs_exi_device_report_match_abandonment(uintptr_t instance_ptr, const char *match_id); -/// Calls through to `SlippiGameReporter::push_replay_data`. +/** + * Calls through to `SlippiGameReporter::push_replay_data`. + */ void slprs_exi_device_reporter_push_replay_data(uintptr_t instance_ptr, const uint8_t *data, uint32_t length); -/// Configures the Jukebox process. This needs to be called after the EXI device is created -/// in order for certain pieces of Dolphin to be properly initalized; this may change down -/// the road though and is not set in stone. +/** + * Configures the Jukebox process. This needs to be called after the EXI device is created + * in order for certain pieces of Dolphin to be properly initalized; this may change down + * the road though and is not set in stone. + */ void slprs_exi_device_configure_jukebox(uintptr_t exi_device_instance_ptr, bool is_enabled, uint8_t initial_dolphin_system_volume, uint8_t initial_dolphin_music_volume); -/// Creates a new Player Report and leaks it, returning the pointer. -/// -/// This should be passed on to a GameReport for processing. +/** + * Creates a new Player Report and leaks it, returning the pointer. + * + * This should be passed on to a GameReport for processing. + */ uintptr_t slprs_player_report_create(const char *uid, uint8_t slot_type, double damage_done, @@ -127,14 +170,16 @@ uintptr_t slprs_player_report_create(const char *uid, int64_t starting_stocks, int64_t starting_percent); -/// Creates a new GameReport and leaks it, returning the instance pointer -/// after doing so. -/// -/// This is expected to ultimately be passed to the game reporter, which will handle -/// destruction and cleanup. +/** + * Creates a new GameReport and leaks it, returning the instance pointer + * after doing so. + * + * This is expected to ultimately be passed to the game reporter, which will handle + * destruction and cleanup. + */ uintptr_t slprs_game_report_create(const char *uid, const char *play_key, - SlippiMatchmakingOnlinePlayMode online_mode, + enum SlippiMatchmakingOnlinePlayMode online_mode, const char *match_id, uint32_t duration_frames, uint32_t game_index, @@ -144,116 +189,193 @@ uintptr_t slprs_game_report_create(const char *uid, int8_t lras_initiator, int32_t stage_id); -/// Takes ownership of the `PlayerReport` at the specified pointer, adding it to the -/// `GameReport` at the corresponding pointer. +/** + * Takes ownership of the `PlayerReport` at the specified pointer, adding it to the + * `GameReport` at the corresponding pointer. + */ void slprs_game_report_add_player_report(uintptr_t instance_ptr, uintptr_t player_report_instance_ptr); -/// Calls through to `Jukebox::start_song`. +/** + * Calls through to `Jukebox::start_song`. + */ void slprs_jukebox_start_song(uintptr_t exi_device_instance_ptr, uint64_t hps_offset, uintptr_t hps_length); -/// Calls through to `Jukebox::stop_music`. +/** + * Calls through to `Jukebox::stop_music`. + */ void slprs_jukebox_stop_music(uintptr_t exi_device_instance_ptr); -/// Calls through to `Jukebox::set_volume` with the Melee volume control. +/** + * Calls through to `Jukebox::set_volume` with the Melee volume control. + */ void slprs_jukebox_set_melee_music_volume(uintptr_t exi_device_instance_ptr, uint8_t volume); -/// Calls through to `Jukebox::set_volume` with the DolphinSystem volume control. +/** + * Calls through to `Jukebox::set_volume` with the DolphinSystem volume control. + */ void slprs_jukebox_set_dolphin_system_volume(uintptr_t exi_device_instance_ptr, uint8_t volume); -/// Calls through to `Jukebox::set_volume` with the DolphinMusic volume control. +/** + * Calls through to `Jukebox::set_volume` with the DolphinMusic volume control. + */ void slprs_jukebox_set_dolphin_music_volume(uintptr_t exi_device_instance_ptr, uint8_t volume); -/// This should be called from the Dolphin LogManager initialization to ensure that -/// all logging needs on the Rust side are configured appropriately. -/// -/// For more information, consult `dolphin_logger::init`. -/// -/// Note that `logger_fn` cannot be type-aliased here, otherwise cbindgen will -/// mess up the header output. That said, the function type represents: -/// -/// ``` -/// void Log(level, log_type, msg); -/// ``` +/** + * This should be called from the Dolphin LogManager initialization to ensure that + * all logging needs on the Rust side are configured appropriately. + * + * For more information, consult `dolphin_logger::init`. + * + * Note that `logger_fn` cannot be type-aliased here, otherwise cbindgen will + * mess up the header output. That said, the function type represents: + * + * ``` + * void Log(level, log_type, msg); + * ``` + */ void slprs_logging_init(void (*logger_fn)(int, int, const char*)); -/// Registers a log container, which mirrors a Dolphin `LogContainer` (`RustLogContainer`). -/// -/// See `dolphin_logger::register_container` for more information. +/** + * Registers a log container, which mirrors a Dolphin `LogContainer` (`RustLogContainer`). + * + * See `dolphin_logger::register_container` for more information. + */ void slprs_logging_register_container(const char *kind, int log_type, bool is_enabled, int default_log_level); -/// Updates the configuration for a registered logging container. -/// -/// For more information, see `dolphin_logger::update_container`. +/** + * Updates the configuration for a registered logging container. + * + * For more information, see `dolphin_logger::update_container`. + */ void slprs_logging_update_container(const char *kind, bool enabled, int level); -/// Updates the configuration for registered logging container on mainline -/// -/// For more information, see `dolphin_logger::update_container`. +/** + * Updates the configuration for registered logging container on mainline + * + * For more information, see `dolphin_logger::update_container`. + */ void slprs_mainline_logging_update_log_level(int level); -/// Instructs the `UserManager` on the EXI Device at the provided pointer to attempt -/// authentication. This runs synchronously on whatever thread it's called on. +/** + * Instructs the `UserManager` on the EXI Device at the provided pointer to attempt + * authentication. This runs synchronously on whatever thread it's called on. + */ bool slprs_user_attempt_login(uintptr_t exi_device_instance_ptr); -/// Instructs the `UserManager` on the EXI Device at the provided pointer to try to -/// open the login page in a system-provided browser view. +/** + * Instructs the `UserManager` on the EXI Device at the provided pointer to try to + * open the login page in a system-provided browser view. + */ void slprs_user_open_login_page(uintptr_t exi_device_instance_ptr); -/// Instructs the `UserManager` on the EXI Device at the provided pointer to attempt -/// to initiate the older update flow. +/** + * Instructs the `UserManager` on the EXI Device at the provided pointer to attempt + * to initiate the older update flow. + */ bool slprs_user_update_app(uintptr_t exi_device_instance_ptr); -/// Instructs the `UserManager` on the EXI Device at the provided pointer to start watching -/// for the presence of a `user.json` file. The `UserManager` should have the requisite path -/// already from EXI device instantiation. +/** + * Instructs the `UserManager` on the EXI Device at the provided pointer to start watching + * for the presence of a `user.json` file. The `UserManager` should have the requisite path + * already from EXI device instantiation. + */ void slprs_user_listen_for_login(uintptr_t exi_device_instance_ptr); -/// Instructs the `UserManager` on the EXI Device at the provided pointer to sign the user out. -/// This will delete the `user.json` file from the underlying filesystem. +/** + * Instructs the `UserManager` on the EXI Device at the provided pointer to sign the user out. + * This will delete the `user.json` file from the underlying filesystem. + */ void slprs_user_logout(uintptr_t exi_device_instance_ptr); -/// Hooks through the `UserManager` on the EXI Device at the provided pointer to overwrite the -/// latest version field on the current user. +/** + * Hooks through the `UserManager` on the EXI Device at the provided pointer to overwrite the + * latest version field on the current user. + */ void slprs_user_overwrite_latest_version(uintptr_t exi_device_instance_ptr, const char *version); -/// Hooks through the `UserManager` on the EXI Device at the provided pointer to determine -/// authentication status. +/** + * Hooks through the `UserManager` on the EXI Device at the provided pointer to determine + * authentication status. + */ bool slprs_user_get_is_logged_in(uintptr_t exi_device_instance_ptr); -/// Hooks through the `UserManager` on the EXI Device at the provided pointer to get information -/// for the current user. This then wraps it in a C struct to pass back so that ownership is safely -/// moved. -/// -/// This involves slightly more allocations than ideal, so this shouldn't be called in a hot path. -/// Over time this issue will not matter as once Matchmaking is moved to Rust we can share things -/// quite easily. -RustUserInfo *slprs_user_get_info(uintptr_t exi_device_instance_ptr); - -/// Takes ownership back of a `UserInfo` struct and drops it. -/// -/// When the C/C++ side grabs `UserInfo`, it needs to ensure that it's passed back to Rust -/// to ensure that the memory layout matches - do _not_ call `free` on `UserInfo`, pass it here -/// instead. -void slprs_user_free_info(RustUserInfo *ptr); - -/// Returns a C-compatible struct containing the chat message options for the current user. -/// -/// The return value of this _must_ be passed back to `slprs_user_free_messages` to free memory. -RustChatMessages *slprs_user_get_messages(uintptr_t exi_device_instance_ptr); - -/// Returns a C-compatible struct containing the default chat message options. -/// -/// The return value of this _must_ be passed back to `slprs_user_free_messages` to free memory. -RustChatMessages *slprs_user_get_default_messages(uintptr_t exi_device_instance_ptr); - -/// Takes back ownership of a `RustChatMessages` instance and frees the underlying data -/// by converting it into the proper Rust types. -void slprs_user_free_messages(RustChatMessages *ptr); - +/** + * Hooks through the `UserManager` on the EXI Device at the provided pointer to get information + * for the current user. This then wraps it in a C struct to pass back so that ownership is safely + * moved. + * + * This involves slightly more allocations than ideal, so this shouldn't be called in a hot path. + * Over time this issue will not matter as once Matchmaking is moved to Rust we can share things + * quite easily. + */ +struct RustUserInfo *slprs_user_get_info(uintptr_t exi_device_instance_ptr); + +/** + * Takes ownership back of a `UserInfo` struct and drops it. + * + * When the C/C++ side grabs `UserInfo`, it needs to ensure that it's passed back to Rust + * to ensure that the memory layout matches - do _not_ call `free` on `UserInfo`, pass it here + * instead. + */ +void slprs_user_free_info(struct RustUserInfo *ptr); + +/** + * Returns a C-compatible struct containing the chat message options for the current user. + * + * The return value of this _must_ be passed back to `slprs_user_free_messages` to free memory. + */ +struct RustChatMessages *slprs_user_get_messages(uintptr_t exi_device_instance_ptr); + +/** + * Returns a C-compatible struct containing the default chat message options. + * + * The return value of this _must_ be passed back to `slprs_user_free_messages` to free memory. + */ +struct RustChatMessages *slprs_user_get_default_messages(uintptr_t exi_device_instance_ptr); + +/** + * Takes back ownership of a `RustChatMessages` instance and frees the underlying data + * by converting it into the proper Rust types. + */ +void slprs_user_free_messages(struct RustChatMessages *ptr); + +/** + * Passes along a direct code to add or update. + */ +void slprs_user_direct_codes_add_or_update(uintptr_t exi_device_instance_ptr, + enum DirectCodeKind kind, + const char *code); + +/** + * Gets the length of the current direct codes stack for the given `kind`. + */ +uint32_t slprs_user_direct_codes_get_length(uintptr_t exi_device_instance_ptr, + enum DirectCodeKind kind); + +/** + * Checks to see if we have a direct code at `index`. + * + * This has the unfortunate aspect of going: Rust String -> CString -> C++ std::string, but + * this will go away over time. Just be aware it's doing more allocations than is perhaps + * ideal... but this area of code isn't performance sensitive anyway as it's not core + * gameplay. + */ +char *slprs_user_direct_codes_get_code_at_index(uintptr_t exi_device_instance_ptr, + enum DirectCodeKind kind, + uintptr_t index); + +/** + * As the allocator on the C++ could be different, we need to provide a `free` method + * that the C++ side will call when it's handled everything it needs to do. + */ +void slprs_user_direct_codes_free_code(char *code); + +#ifdef __cplusplus } // extern "C" +#endif // __cplusplus diff --git a/ffi/src/exi.rs b/ffi/src/exi.rs index 1206964..e5d8f10 100644 --- a/ffi/src/exi.rs +++ b/ffi/src/exi.rs @@ -16,7 +16,7 @@ use crate::c_str_to_string; pub struct SlippiRustEXIConfig { // Paths pub iso_path: *const c_char, - pub user_json_path: *const c_char, + pub user_config_folder: *const c_char, // Git version number pub scm_slippi_semver_str: *const c_char, @@ -52,7 +52,7 @@ pub extern "C" fn slprs_exi_device_create(config: SlippiRustEXIConfig) -> usize let exi_device = Box::new(SlippiEXIDevice::new(Config { paths: FilePathsConfig { iso: c_str_to_string(config.iso_path, fn_name, "iso_path"), - user_json: c_str_to_string(config.user_json_path, fn_name, "user_json"), + user_config_folder: c_str_to_string(config.user_config_folder, fn_name, "user_config_folder"), }, scm: SCMConfig { diff --git a/ffi/src/user.rs b/ffi/src/user.rs index 7d44e90..1179f9d 100644 --- a/ffi/src/user.rs +++ b/ffi/src/user.rs @@ -235,3 +235,72 @@ pub extern "C" fn slprs_user_free_messages(ptr: *mut RustChatMessages) { } } } + +/// Indicates what type of direct code operation we're in. +#[repr(C)] +pub enum DirectCodeKind { + DirectCodes = 1, + TeamsCodes = 2, +} + +/// Passes along a direct code to add or update. +#[no_mangle] +pub extern "C" fn slprs_user_direct_codes_add_or_update( + exi_device_instance_ptr: usize, + kind: DirectCodeKind, + code: *const c_char, +) { + let code = c_str_to_string(code, "slprs_user_add_or_update_direct_code", "code"); + + with::(exi_device_instance_ptr, move |device| match kind { + DirectCodeKind::DirectCodes => { + device.user_manager.direct_codes.add_or_update_code(code); + }, + + DirectCodeKind::TeamsCodes => { + device.user_manager.teams_direct_codes.add_or_update_code(code); + }, + }); +} + +/// Gets the length of the current direct codes stack for the given `kind`. +#[no_mangle] +pub extern "C" fn slprs_user_direct_codes_get_length(exi_device_instance_ptr: usize, kind: DirectCodeKind) -> u32 { + with_returning::(exi_device_instance_ptr, move |device| match kind { + DirectCodeKind::DirectCodes => device.user_manager.direct_codes.len() as u32, + DirectCodeKind::TeamsCodes => device.user_manager.teams_direct_codes.len() as u32, + }) +} + +/// Checks to see if we have a direct code at `index`. +/// +/// This has the unfortunate aspect of going: Rust String -> CString -> C++ std::string, but +/// this will go away over time. Just be aware it's doing more allocations than is perhaps +/// ideal... but this area of code isn't performance sensitive anyway as it's not core +/// gameplay. +#[no_mangle] +pub extern "C" fn slprs_user_direct_codes_get_code_at_index( + exi_device_instance_ptr: usize, + kind: DirectCodeKind, + index: usize, +) -> *mut c_char { + let code = with_returning::(exi_device_instance_ptr, move |device| match kind { + DirectCodeKind::DirectCodes => device.user_manager.direct_codes.get(index), + DirectCodeKind::TeamsCodes => device.user_manager.teams_direct_codes.get(index), + }); + + CString::new(code.as_bytes()) + .expect("Unable to convert direct code to CString") + .into_raw() +} + +/// As the allocator on the C++ could be different, we need to provide a `free` method +/// that the C++ side will call when it's handled everything it needs to do. +#[no_mangle] +pub extern "C" fn slprs_user_direct_codes_free_code(code: *mut c_char) { + unsafe { + if !code.is_null() { + let _ = CString::from_raw(code); + } + } +} diff --git a/user/Cargo.toml b/user/Cargo.toml index f75f0e0..dde029d 100644 --- a/user/Cargo.toml +++ b/user/Cargo.toml @@ -20,5 +20,6 @@ dolphin-integrations = { path = "../dolphin" } open = "5" serde = { workspace = true } serde_json = { workspace = true } +time = { workspace = true } tracing = { workspace = true } ureq = { workspace = true } diff --git a/user/src/direct_codes/last_played_parser.rs b/user/src/direct_codes/last_played_parser.rs new file mode 100644 index 0000000..8c699d8 --- /dev/null +++ b/user/src/direct_codes/last_played_parser.rs @@ -0,0 +1,54 @@ +//! Implements deserialization/parsing the `last_played` field from direct +//! code file payloads. This will decode from either a unix timestamp *or* +//! an older used datetime string format. +//! +//! Subsequent writes to the direct codes file(s) will have their timstamps +//! written as i64 unix timestamps. This could potentially be done away with +//! after a few releases - just stub in the time crate macro for auto-generating +//! unix timestamp handling code. + +use serde::{Deserialize, Serialize}; +use time::macros::format_description; +use time::OffsetDateTime; + +/// Serializes a timestamp as a unix timestamp (`i64`). +pub fn serialize(datetime: &OffsetDateTime, serializer: S) -> Result +where + S: serde::Serializer, +{ + datetime.unix_timestamp().serialize(serializer) +} + +/// Attempts deserialiazation of the `last_played` field, by first checking if it's a +/// unix timestamp and falling back to the older timestamp format if not. +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + + if let Some(timestamp) = value.as_i64() { + return OffsetDateTime::from_unix_timestamp(timestamp).map_err(serde::de::Error::custom); + } + + if let Some(datetime_str) = value.as_str() { + let tsfmt = format_description!("[year][month][day]T[offset_hour][offset_minute][offset_second]"); + + return OffsetDateTime::parse(datetime_str, &tsfmt).map_err(serde::de::Error::custom); + } + + Err(serde::de::Error::custom(format!( + "Invalid last_played type in direct codes file: {:?}", + value + ))) +} + +// Auto-generate serde parsers for the lastPlayed JSON field. +// Once we hit a point where we could just assume unix timestamps for all players, this module +// could go away and this macro could just be shoved into `mod.rs` - probably with a bit of +// tweaking but that's the gist of things. +/*time::serde::format_description!( + last_played_parser, + OffsetDateTime, + "[year][month][day]T[offset_hour][offset_minute][offset_second]" +);*/ diff --git a/user/src/direct_codes/mod.rs b/user/src/direct_codes/mod.rs new file mode 100644 index 0000000..b2c3d50 --- /dev/null +++ b/user/src/direct_codes/mod.rs @@ -0,0 +1,198 @@ +//! Direct codes are used on the connect screen as a form of history (codes +//! that have been recently connected to). + +use std::borrow::Cow; +use std::fs; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use time::OffsetDateTime; + +use dolphin_integrations::Log; + +mod last_played_parser; + +/// Indicates how a sort of the direct codes should be done. +#[derive(Debug)] +enum SortBy { + // This sort type is not used at the moment, but was stubbed + // out in the C++ version. It's kept around commented out for + // marking potential future intentions. + // Name, + LastPlayed, +} + +/// The actual payload that's serialized back and forth to disk. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct DirectCode { + #[serde(rename = "connectCode", alias = "connect_code")] + pub connect_code: String, + + #[serde(rename = "lastPlayed", alias = "last_played", with = "last_played_parser")] + pub last_played: OffsetDateTime, + // This doesn't exist yet and is stubbed to match the C++ version, + // which had some inkling of it - and could always be used in the + // future. + // #[serde(rename = "favorite")] + // pub is_favorite: Option +} + +/// A wrapper around a list of direct codes. The main entry point for querying, +/// sorting, and adding codes. This type is thread safe and be freely cloned and +/// passed around, though realistically only the user manager should need it. +#[derive(Clone, Debug)] +pub struct DirectCodes { + path: Arc, + codes: Arc>>, +} + +impl DirectCodes { + /// Given a `path` that points to a user direct codes JSON file, will attempt + /// to load and deserialize the data. If either fails, this will log a message + /// indicating there's an issue but it will not error out - the underlying payload + /// will simply be empty. + pub fn load(path: PathBuf) -> Self { + tracing::info!(target: Log::SlippiOnline, ?path, "Attempting to load direct codes"); + + let mut codes = Vec::new(); + + match fs::read_to_string(path.as_path()) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(parsed) => { + codes = parsed; + }, + + Err(error) => { + tracing::error!(?error, "Unable to parse direct codes file"); + }, + }, + + Err(error) => { + tracing::error!(?error, "Unable to read direct codes file"); + }, + } + + Self { + path: Arc::new(path), + codes: Arc::new(Mutex::new(codes)), + } + } + + /// Sorts the underlying direct codes list by the `sort_by` parameter. + fn sort(codes: &mut Vec, sort_by: SortBy) { + match sort_by { + SortBy::LastPlayed => { + codes.sort_by(|a, b| a.last_played.cmp(&b.last_played)); + }, + } + } + + /// Returns the length of the underlying direct codes list. + /// + /// This could generally be done with `Deref`, but needing a custom `sort` leads + /// me to think that this will be more clear in the long term how delegation is + /// happening. + pub fn len(&self) -> usize { + let codes = self.codes.lock().expect("Unable to lock codes for len check"); + + codes.len() + } + + /// Attempts to get the connect code at the specified index. + /// + /// This utilizes `Cow` (Copy-On-Write) to avoid extra allocations where + /// we don't perhaps need them. + pub fn get(&self, index: usize) -> Cow<'static, str> { + let mut codes = self.codes.lock().expect("Unable to lock codes for autocomplete"); + + Self::sort(&mut codes, SortBy::LastPlayed); + + if let Some(entry) = codes.get(index) { + return Cow::Owned(entry.connect_code.clone()); + } + + tracing::info!(target: Log::SlippiOnline, ?index, "Potential out of bounds name entry index"); + + Cow::Borrowed(match index >= codes.len() { + true => "1", + false => "", + }) + } + + /// Adds or updates a direct code. + /// + /// If it's an update, we're just updating the timestamp so that future sorts + /// order it appropriately. + pub fn add_or_update_code(&self, code: String) { + tracing::warn!(target: Log::SlippiOnline, ?code, "Attempting to add or update direct code"); + + let last_played = OffsetDateTime::now_utc(); + + let mut codes = self.codes.lock().expect("Unable to lock codes for autocomplete"); + + let mut found = false; + for mut entry in codes.iter_mut() { + if entry.connect_code == code { + found = true; + entry.last_played = last_played; + } + } + + if !found { + codes.push(DirectCode { + connect_code: code, + last_played, + }); + } + + // Consider moving this to a background thread if the performance of + // `write_file` ever becomes an issue. In practice, it's never been one. + Self::write_file(self.path.as_path(), &codes); + } + + /* The below code is not used at the moment, but stubbed out to match the C++ side. + /// Attempts to autocomplete a code based off of the start text. + pub fn autocomplete(&self, start_text: &str) -> Option { + let mut codes = self.codes.lock() + .expect("Unable to lock codes for autocomplete"); + + Self::sort(&mut codes, SortBy::Time); + + for code in codes.iter() { + if code.connect_code.as_str().starts_with(start_text) { + return Some(code.connect_code.clone()); + } + } + + None + }*/ + + /// Serializes and writes the contents of `codes` to disk at `path`. + fn write_file(path: &Path, codes: &[DirectCode]) { + match fs::File::create(path) { + Ok(file) => { + let mut writer = BufWriter::new(file); + + if let Err(error) = serde_json::to_writer(&mut writer, codes) { + tracing::error!(target: Log::SlippiOnline, ?error, "Unable to write direct codes to disk"); + return; + } + + if let Err(error) = writer.flush() { + tracing::error!(target: Log::SlippiOnline, ?error, "Unable to flush direct codes file to disk"); + return; + } + }, + + Err(error) => { + tracing::error!( + target: Log::SlippiOnline, + ?error, + ?path, + "Unable to open direct codes file for write" + ); + }, + } + } +} diff --git a/user/src/lib.rs b/user/src/lib.rs index dc37b04..e839b50 100644 --- a/user/src/lib.rs +++ b/user/src/lib.rs @@ -6,11 +6,12 @@ use std::sync::{Arc, Mutex}; use ureq::Agent; -// use dolphin_integrations::Log; - mod chat; pub use chat::DEFAULT_CHAT_MESSAGES; +mod direct_codes; +use direct_codes::DirectCodes; + mod watcher; use watcher::UserInfoWatcher; @@ -44,7 +45,6 @@ impl UserInfo { /// Mostly checks to make sure we're not loading or receiving anything undesired. pub fn sanitize(&mut self) { if self.chat_messages.is_none() || self.chat_messages.as_ref().unwrap().len() != 16 { - // if self.chat_messages.len() != 16 { self.chat_messages = Some(chat::default()); } } @@ -61,6 +61,8 @@ pub struct UserManager { http_client: Agent, user: Arc>, user_json_path: Arc, + pub direct_codes: DirectCodes, + pub teams_direct_codes: DirectCodes, slippi_semver: String, watcher: Arc>, } @@ -74,15 +76,33 @@ impl UserManager { /// this restriction via some assumptions. // @TODO: The semver param here should get refactored away in time once we've ironed out // how some things get persisted from the Dolphin side. Not a big deal to thread it for now. - pub fn new(http_client: Agent, user_json_path: PathBuf, slippi_semver: String) -> Self { + pub fn new(http_client: Agent, mut user_config_folder: PathBuf, slippi_semver: String) -> Self { + let direct_codes = DirectCodes::load({ + let mut path = user_config_folder.clone(); + path.push("direct-codes.json"); + path + }); + + let teams_direct_codes = DirectCodes::load({ + let mut path = user_config_folder.clone(); + path.push("teams-codes.json"); + path + }); + + let user_json_path = Arc::new({ + user_config_folder.push("user.json"); + user_config_folder + }); + let user = Arc::new(Mutex::new(UserInfo::default())); - let user_json_path = Arc::new(user_json_path); let watcher = Arc::new(Mutex::new(UserInfoWatcher::new())); Self { http_client, user, user_json_path, + direct_codes, + teams_direct_codes, slippi_semver, watcher, } @@ -96,9 +116,6 @@ impl UserManager { /// This is slightly better ergonomics wise than dealing with locking all over the place, and /// allows batch retrieval of properties. /// - /// If, in the rare event that a Mutex lock could not be acquired (which should... never - /// happen), this will call the provided closure with `&None` while logging the error. - /// /// ```no_run /// use slippi_user::UserManager; ///