diff --git a/.github/scripts/build-macos b/.github/scripts/build-macos index 1004c230..74bf7a96 100755 --- a/.github/scripts/build-macos +++ b/.github/scripts/build-macos @@ -170,7 +170,7 @@ build() { # Run tests if requested if (( run_tests )) { log_group "Building test targets..." - cmake --build build_macos --config ${config} --target test_encoder test_crypto test_convert test_parsers test_xbox_session test_types + cmake --build build_macos --config ${config} --target test_encoder test_crypto test_convert test_parsers test_monitoring_service test_xbox_session test_types log_group "Running tests..." ctest --test-dir build_macos --build-config ${config} --output-on-failure diff --git a/.github/workflows/build-project.yaml b/.github/workflows/build-project.yaml index 4b575e30..f52b34a3 100644 --- a/.github/workflows/build-project.yaml +++ b/.github/workflows/build-project.yaml @@ -420,6 +420,13 @@ jobs: - name: Install Dependencies πŸ” run: | + # Update vcpkg to the latest port definitions before installing. + # The port snapshots bundled with the windows-2022 runner image can + # become stale: if GitHub regenerates a release tarball the recorded + # SHA-512 hash in the port no longer matches and the download fails. + # Pulling the latest vcpkg tree ensures we get corrected hashes. + git -C "$env:VCPKG_INSTALLATION_ROOT" pull --ff-only + # Install OpenSSL and libwebsockets for x64 via vcpkg. # The obs-deps prebuilt package does not ship either on Windows. # The system OpenSSL must be replaced with the vcpkg one: the system @@ -471,6 +478,10 @@ jobs: - name: Install OpenSSL for ARM64 πŸ” run: | + # Update vcpkg to the latest port definitions before installing. + # See the x64 step for the full explanation. + git -C "$env:VCPKG_INSTALLATION_ROOT" pull --ff-only + # Install OpenSSL and libwebsockets for ARM64 via vcpkg. # The windows-2022 runner ships vcpkg at $env:VCPKG_INSTALLATION_ROOT. # The system OpenSSL is x64-only; we need an arm64-windows build. diff --git a/CMakeLists.txt b/CMakeLists.txt index 93dea14e..53be809b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -477,14 +477,14 @@ target_sources( ${CMAKE_PROJECT_NAME} PRIVATE src/main.c - src/sources/xbox/gamerpic.c - src/sources/xbox/game_cover.c - src/sources/xbox/gamerscore.c - src/sources/xbox/gamertag.c - src/sources/xbox/achievement_name.c - src/sources/xbox/achievement_description.c - src/sources/xbox/achievement_icon.c - src/sources/xbox/achievements_count.c + src/sources/gamerpic.c + src/sources/game_cover.c + src/sources/gamerscore.c + src/sources/gamertag.c + src/sources/achievement_name.c + src/sources/achievement_description.c + src/sources/achievement_icon.c + src/sources/achievements_count.c src/sources/common/text_source.c src/sources/common/image_source.c src/sources/common/achievement_cycle.c @@ -494,12 +494,14 @@ target_sources( src/net/browser/browser.c src/net/http/http.c src/net/json/json.c - src/oauth/util.c - src/oauth/xbox-live.c - src/xbox/account_manager.c - src/xbox/xbox_session.c - src/xbox/xbox_client.c - src/xbox/xbox_monitor.c + src/integrations/xbox/oauth/util.c + src/integrations/xbox/oauth/xbox-live.c + src/integrations/xbox/account_manager.c + src/integrations/xbox/xbox_session.c + src/integrations/xbox/xbox_client.c + src/integrations/xbox/xbox_monitor.c + src/integrations/monitoring_service.c + src/integrations/retro-achievements/retro_achievements_monitor.c src/ui/xbox_account_config.cpp src/io/state.c src/io/cache.c @@ -509,14 +511,16 @@ target_sources( src/text/parsers.c src/time/time.c src/common/achievement.c - src/common/achievement_progress.c src/common/device.c src/common/game.c src/common/gamerscore.c + src/common/identity.c src/common/token.c - src/common/unlocked_achievement.c - src/common/xbox_identity.c - src/common/xbox_session.c + src/integrations/xbox/contracts/xbox_achievement.c + src/integrations/xbox/contracts/xbox_achievement_progress.c + src/integrations/xbox/contracts/xbox_unlocked_achievement.c + src/integrations/xbox/entities/xbox_identity.c + src/integrations/xbox/entities/xbox_session.c ) # Link vendored deps @@ -721,6 +725,49 @@ if(BUILD_TESTING) target_link_test_deps(test_parsers) + # ------------------------------ + # test_monitoring_service + # ------------------------------ + add_executable( + test_monitoring_service + test/test_monitoring_service.c + ${unity_SOURCE_DIR}/src/unity.c + src/integrations/monitoring_service.c + src/common/achievement.c + src/common/game.c + src/common/gamerscore.c + src/common/identity.c + src/common/token.c + src/integrations/xbox/contracts/xbox_achievement.c + src/integrations/xbox/contracts/xbox_achievement_progress.c + src/integrations/xbox/contracts/xbox_unlocked_achievement.c + src/integrations/xbox/entities/xbox_identity.c + test/stubs/bmem_stub.c + test/stubs/integrations/xbox_monitor_stub.c + test/stubs/integrations/retro_achievements_monitor_stub.c + test/stubs/io/cache_stub.c + test/stubs/time/time_stub.c + ) + + add_test(NAME test_monitoring_service COMMAND test_monitoring_service) + + if(ENABLE_COVERAGE) + enable_coverage(test_monitoring_service) + endif() + + target_include_directories( + test_monitoring_service + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/test/stubs + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${unity_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/test + ) + + target_compile_definitions(test_monitoring_service PRIVATE UNITY_INCLUDE_CONFIG_H) + + target_link_test_deps(test_monitoring_service) + # ------------------------------ # test_xbox_session # ------------------------------ @@ -728,14 +775,15 @@ if(BUILD_TESTING) test_xbox_session test/test_xbox_session.c ${unity_SOURCE_DIR}/src/unity.c - src/xbox/xbox_session.c + src/integrations/xbox/xbox_session.c src/common/achievement.c - src/common/achievement_progress.c src/common/game.c src/common/gamerscore.c src/common/token.c - src/common/unlocked_achievement.c - src/common/xbox_session.c + src/integrations/xbox/contracts/xbox_achievement.c + src/integrations/xbox/contracts/xbox_achievement_progress.c + src/integrations/xbox/contracts/xbox_unlocked_achievement.c + src/integrations/xbox/entities/xbox_session.c test/stubs/bmem_stub.c test/stubs/xbox/xbox_client_stub.c test/stubs/time/time_stub.c @@ -768,14 +816,15 @@ if(BUILD_TESTING) test_types test/test_types.c ${unity_SOURCE_DIR}/src/unity.c - src/xbox/xbox_session.c + src/integrations/xbox/xbox_session.c src/common/achievement.c - src/common/achievement_progress.c src/common/game.c src/common/gamerscore.c src/common/token.c - src/common/unlocked_achievement.c - src/common/xbox_session.c + src/integrations/xbox/contracts/xbox_achievement.c + src/integrations/xbox/contracts/xbox_achievement_progress.c + src/integrations/xbox/contracts/xbox_unlocked_achievement.c + src/integrations/xbox/entities/xbox_session.c test/stubs/bmem_stub.c test/stubs/xbox/xbox_client_stub.c test/stubs/time/time_stub.c @@ -805,6 +854,6 @@ if(BUILD_TESTING) # Coverage target (must be after all test targets are defined) # ------------------------------ if(ENABLE_COVERAGE) - add_coverage_target(test_encoder test_crypto test_convert test_parsers test_xbox_session test_types) + add_coverage_target(test_encoder test_crypto test_convert test_parsers test_monitoring_service test_xbox_session test_types) endif() endif() diff --git a/README.md b/README.md index 1a52b5a6..fb64c6b0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # OBS Achievements Tracker -A cross-platform OBS Studio plugin that displays Xbox Live profile data, current game information, and achievement progress for the signed-in Xbox user. +A cross-platform OBS Studio plugin that displays Xbox Live and RetroAchievements profile data, current game information, and achievement progress for the signed-in user. ## Features - **Global Xbox account configuration dialog** using Microsoft's device-code flow - **Real-time game and achievement tracking** through Xbox Live RTA monitoring when available +- **RetroAchievements integration** via a local RetroArch WebSocket server for retro game tracking +- **Unified monitoring service** that handles both Xbox and RetroAchievements sessions with last-game-received priority - **Profile sources** for gamertag, gamerpic, and gamerscore - **Achievement sources** for name, description, icon, and progress count - **Customizable text sources** with persisted font and gradient color settings @@ -59,32 +61,32 @@ After installation, restart OBS Studio. 3. Use the global Xbox Account dialog to review the current status and click **Sign in with Xbox**. 4. A browser window opens for Microsoft account authentication. 5. Once authentication succeeds, return to OBS. The dialog will update to show the connected account. -6. Add any of the Xbox display sources you want to use in your scene. +6. Add any of the display sources you want to use in your scene. -All Xbox sources in the plugin share the same authenticated account. +All Xbox sources in the plugin share the same authenticated account. RetroAchievements sources connect automatically when a RetroArch WebSocket server is detected on the local machine. -![Xbox Account dialog](plugin-xbox-account.png) +![Xbox Account dialog](images/plugin-xbox-account.png) ### Available OBS Sources #### Account & profile -- **Xbox Gamertag**: text source for the current gamertag -- **Xbox Gamerpic**: image source for the current gamerpic -- **Xbox Gamerscore**: text source for the current gamerscore +- **Gamertag**: text source for the current gamertag or RetroAchievements display name +- **Gamerpic**: image source for the current gamerpic or RetroAchievements avatar +- **Gamerscore**: text source for the current gamerscore or RetroAchievements score Account sign-in and sign-out are managed globally from **Tools** β†’ **Xbox Account**. #### Game -- **Xbox Game Cover**: image source for the currently active game's cover art +- **Game Cover**: image source for the currently active game's cover art #### Achievements -- **Xbox Achievement (Name)**: current achievement name, including gamerscore when available -- **Xbox Achievement (Description)**: current achievement description -- **Xbox Achievement (Icon)**: current achievement icon -- **Xbox Achievements Count**: unlocked / total achievements for the current game (for example `12 / 50`) +- **Achievement (Name)**: current achievement name, including gamerscore when available +- **Achievement (Description)**: current achievement description +- **Achievement (Icon)**: current achievement icon +- **Achievements’ Count**: unlocked / total achievements for the current game (for example `12 / 50`) #### Real-time updates @@ -93,6 +95,14 @@ When Xbox Live monitoring is available, the plugin subscribes to: - current game / presence changes - achievement progression updates +When a local RetroArch WebSocket server is detected, the plugin additionally tracks: + +- current retro game changes +- achievement list and unlock updates +- user identity (display name, score, avatar) + +The active identity shown in profile sources is determined by whichever integration last reported a game change. If only one integration has an active game, that integration's identity is used. + Profile-derived sources such as gamerscore, gamertag, and gamerpic refresh from the authenticated session data used by the plugin. --- @@ -104,27 +114,63 @@ Profile-derived sources such as gamerscore, gamertag, and gamerpic refresh from ```text achievements-tracker-plugin/ β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ main.c # OBS module entry point -β”‚ β”œβ”€β”€ common/ # Shared types and value objects -β”‚ β”œβ”€β”€ crypto/ # Proof-of-possession signing helpers -β”‚ β”œβ”€β”€ diagnostics/ # Logging helpers -β”‚ β”œβ”€β”€ drawing/ # Color and image rendering helpers -β”‚ β”œβ”€β”€ encoding/ # Base64 helpers -β”‚ β”œβ”€β”€ io/ # Persistent state and cache helpers -β”‚ β”œβ”€β”€ net/ # Browser, HTTP, and JSON helpers -β”‚ β”œβ”€β”€ oauth/ # Xbox/Microsoft authentication flow +β”‚ β”œβ”€β”€ main.c # OBS module entry point +β”‚ β”œβ”€β”€ common/ # Shared platform-agnostic types and value objects +β”‚ β”‚ β”œβ”€β”€ achievement.{c,h} # Generic achievement abstraction +β”‚ β”‚ β”œβ”€β”€ game.{c,h} # Generic game abstraction +β”‚ β”‚ β”œβ”€β”€ gamerscore.{c,h} # Gamerscore value object +β”‚ β”‚ β”œβ”€β”€ identity.{c,h} # Unified user identity (Xbox + RetroAchievements) +β”‚ β”‚ └── token.{c,h} # Auth token value object +β”‚ β”œβ”€β”€ crypto/ # Proof-of-possession signing helpers +β”‚ β”œβ”€β”€ diagnostics/ # Logging helpers +β”‚ β”œβ”€β”€ drawing/ # Color and image rendering helpers +β”‚ β”œβ”€β”€ encoding/ # Base64 helpers +β”‚ β”œβ”€β”€ integrations/ +β”‚ β”‚ β”œβ”€β”€ monitoring_service.{c,h} # Unified event fan-out for all integrations +β”‚ β”‚ β”œβ”€β”€ retro-achievements/ # RetroAchievements WebSocket monitor +β”‚ β”‚ └── xbox/ +β”‚ β”‚ β”œβ”€β”€ account_manager.{c,h} # Xbox account lifecycle +β”‚ β”‚ β”œβ”€β”€ contracts/ # Xbox-specific wire types (achievements, progress) +β”‚ β”‚ β”œβ”€β”€ entities/ # Xbox identity and session value objects +β”‚ β”‚ β”œβ”€β”€ oauth/ # Xbox/Microsoft authentication flow +β”‚ β”‚ β”œβ”€β”€ xbox_client.{c,h} # Xbox REST API client +β”‚ β”‚ β”œβ”€β”€ xbox_monitor.{c,h} # Xbox Live RTA WebSocket monitor +β”‚ β”‚ └── xbox_session.{c,h} # Xbox session state +β”‚ β”œβ”€β”€ io/ # Persistent state and cache helpers +β”‚ β”œβ”€β”€ net/ +β”‚ β”‚ β”œβ”€β”€ browser/ # System browser launcher +β”‚ β”‚ β”œβ”€β”€ http/ # HTTP client helpers +β”‚ β”‚ └── json/ # JSON helpers β”‚ β”œβ”€β”€ sources/ -β”‚ β”‚ β”œβ”€β”€ common/ # Shared text/image source helpers -β”‚ β”‚ └── xbox/ # OBS source implementations -β”‚ β”œβ”€β”€ text/ # Conversion and parsing helpers -β”‚ β”œβ”€β”€ time/ # Time parsing utilities -β”‚ β”œβ”€β”€ util/ # UUID and portability helpers -β”‚ └── xbox/ # Xbox client, monitor, and session logic -β”œβ”€β”€ test/ # Unity-based unit tests and stubs -β”œβ”€β”€ data/ # Locale files and effects/resources -β”œβ”€β”€ external/cjson/ # Vendored cJSON -β”œβ”€β”€ cmake/ # Platform-specific CMake helpers -β”œβ”€β”€ .github/ # CI workflows and composite actions +β”‚ β”‚ β”œβ”€β”€ common/ # Shared text/image source helpers and achievement cycle +β”‚ β”‚ β”œβ”€β”€ achievement_description.{c,h} +β”‚ β”‚ β”œβ”€β”€ achievement_icon.{c,h} +β”‚ β”‚ β”œβ”€β”€ achievement_name.{c,h} +β”‚ β”‚ β”œβ”€β”€ achievements_count.{c,h} +β”‚ β”‚ β”œβ”€β”€ game_cover.{c,h} +β”‚ β”‚ β”œβ”€β”€ gamerpic.{c,h} +β”‚ β”‚ β”œβ”€β”€ gamerscore.{c,h} +β”‚ β”‚ └── gamertag.{c,h} +β”‚ β”œβ”€β”€ text/ # Conversion and parsing helpers +β”‚ β”œβ”€β”€ time/ # Time parsing utilities +β”‚ └── util/ # UUID and portability helpers +β”œβ”€β”€ test/ # Unity-based unit tests and stubs +β”‚ β”œβ”€β”€ stubs/ +β”‚ β”‚ β”œβ”€β”€ integrations/ # Stubs for xbox_monitor and retro_achievements_monitor +β”‚ β”‚ β”œβ”€β”€ io/ # Stub for cache +β”‚ β”‚ β”œβ”€β”€ xbox/ # Stub for xbox_client +β”‚ β”‚ └── ... +β”‚ β”œβ”€β”€ test_convert.c +β”‚ β”œβ”€β”€ test_crypto.c +β”‚ β”œβ”€β”€ test_encoder.c +β”‚ β”œβ”€β”€ test_monitoring_service.c # Tests for the unified monitoring service +β”‚ β”œβ”€β”€ test_parsers.c +β”‚ β”œβ”€β”€ test_types.c +β”‚ └── test_xbox_session.c +β”œβ”€β”€ data/ # Locale files and effects/resources +β”œβ”€β”€ external/cjson/ # Vendored cJSON +β”œβ”€β”€ cmake/ # Platform-specific CMake helpers +β”œβ”€β”€ .github/ # CI workflows and composite actions β”œβ”€β”€ CMakeLists.txt β”œβ”€β”€ CMakePresets.json └── buildspec.json @@ -421,6 +467,9 @@ cmake --build build_macos_dev --target test_convert --config Debug cmake --build build_macos_dev --target test_parsers --config Debug ./build_macos_dev/Debug/test_parsers + +cmake --build build_macos_dev --target test_monitoring_service --config Debug +./build_macos_dev/Debug/test_monitoring_service ``` --- diff --git a/images/plugin-xbox-account.png b/images/plugin-xbox-account.png new file mode 100644 index 00000000..d54b2702 Binary files /dev/null and b/images/plugin-xbox-account.png differ diff --git a/src/common/achievement.c b/src/common/achievement.c index 7a554c85..9d4cf9de 100644 --- a/src/common/achievement.c +++ b/src/common/achievement.c @@ -4,114 +4,8 @@ #include #include - #include -media_asset_t *copy_media_asset(const media_asset_t *media_asset) { - if (!media_asset) { - return NULL; - } - - media_asset_t *root_copy = NULL; - media_asset_t *previous_copy = NULL; - - const media_asset_t *current = media_asset; - - while (current) { - const media_asset_t *next = current->next; - - media_asset_t *copy = bzalloc(sizeof(media_asset_t)); - copy->url = bstrdup(current->url); - - if (previous_copy) { - previous_copy->next = copy; - } - - previous_copy = copy; - current = next; - - if (!root_copy) { - root_copy = copy; - } - } - - return root_copy; -} - -void free_media_asset(media_asset_t **media_asset) { - - if (!media_asset || !*media_asset) { - return; - } - - media_asset_t *current = *media_asset; - - while (current) { - media_asset_t *next = current->next; - - free_memory((void **)¤t->url); - free_memory((void **)¤t); - - current = next; - } - - *media_asset = NULL; -} - -reward_t *copy_reward(const reward_t *reward) { - - if (!reward) { - return NULL; - } - - reward_t *root_copy = NULL; - reward_t *previous_copy = NULL; - - const reward_t *current = reward; - - while (current) { - const reward_t *next = current->next; - - reward_t *copy = bzalloc(sizeof(reward_t)); - - copy->value = bstrdup(current->value); - - if (previous_copy) { - previous_copy->next = copy; - } - - previous_copy = copy; - current = next; - - if (!root_copy) { - root_copy = copy; - } - } - - return root_copy; -} - -void free_reward(reward_t **reward) { - - if (!reward || !*reward) { - return; - } - - reward_t *current = *reward; - - while (current) { - - reward_t *next = current->next; - - free_memory((void **)¤t->value); - free_memory((void **)¤t); - - current = next; - } - - *reward = NULL; -} - achievement_t *copy_achievement(const achievement_t *achievement) { if (!achievement) { @@ -129,16 +23,13 @@ achievement_t *copy_achievement(const achievement_t *achievement) { achievement_t *copy = bzalloc(sizeof(achievement_t)); copy->id = bstrdup(current->id); - copy->description = bstrdup(current->description); - copy->locked_description = bstrdup(current->locked_description); copy->name = bstrdup(current->name); - copy->progress_state = bstrdup(current->progress_state); - copy->service_config_id = bstrdup(current->service_config_id); + copy->description = bstrdup(current->description); copy->icon_url = bstrdup(current->icon_url); - copy->media_assets = copy_media_asset(current->media_assets); - copy->rewards = copy_reward(current->rewards); copy->is_secret = current->is_secret; + copy->value = current->value; copy->unlocked_timestamp = current->unlocked_timestamp; + copy->source = current->source; if (previous_copy) { previous_copy->next = copy; @@ -166,15 +57,10 @@ void free_achievement(achievement_t **achievement) { while (current) { achievement_t *next = current->next; - free_memory((void **)¤t->service_config_id); free_memory((void **)¤t->id); free_memory((void **)¤t->name); free_memory((void **)¤t->description); - free_memory((void **)¤t->locked_description); - free_memory((void **)¤t->progress_state); free_memory((void **)¤t->icon_url); - free_media_asset(¤t->media_assets); - free_reward(¤t->rewards); free_memory((void **)¤t); current = next; @@ -212,7 +98,6 @@ const achievement_t *find_latest_unlocked_achievement(const achievement_t *achie } int count_locked_achievements(const achievement_t *achievements) { - int count = 0; for (const achievement_t *a = achievements; a != NULL; a = a->next) { @@ -271,44 +156,35 @@ void sort_achievements(achievement_t **achievements) { achievement_t *sorted = NULL; achievement_t *current = *achievements; - /* Insertion sort: take each node from the original list and insert it in sorted order */ + /* Insertion sort: unlocked first, then by timestamp descending */ while (current) { achievement_t *next = current->next; - /* Insert current into the sorted list at the correct position */ if (!sorted) { - /* First node in the sorted list */ sorted = current; sorted->next = NULL; } else { - /* Determine if current should go before sorted head */ bool should_insert_before_head = false; if (sorted->unlocked_timestamp == 0 && current->unlocked_timestamp != 0) { - /* Current is unlocked, head is locked */ should_insert_before_head = true; } else if (current->unlocked_timestamp != 0 && sorted->unlocked_timestamp != 0 && current->unlocked_timestamp > sorted->unlocked_timestamp) { - /* Both unlocked, current has more recent timestamp */ should_insert_before_head = true; } if (should_insert_before_head) { - /* Insert at head */ current->next = sorted; sorted = current; } else { - /* Find the correct position in the sorted list */ achievement_t *search = sorted; while (search->next) { bool should_insert_here = false; if (search->next->unlocked_timestamp == 0 && current->unlocked_timestamp != 0) { - /* Current is unlocked, next is locked */ should_insert_here = true; } else if (current->unlocked_timestamp != 0 && search->next->unlocked_timestamp != 0 && current->unlocked_timestamp > search->next->unlocked_timestamp) { - /* Both unlocked, current has more recent timestamp */ should_insert_here = true; } @@ -318,7 +194,6 @@ void sort_achievements(achievement_t **achievements) { search = search->next; } - /* Insert current after search */ current->next = search->next; search->next = current; } diff --git a/src/common/achievement.h b/src/common/achievement.h index 672adff8..c04c080f 100644 --- a/src/common/achievement.h +++ b/src/common/achievement.h @@ -1,7 +1,5 @@ #pragma once -#include "time/time.h" - #include #include @@ -10,124 +8,64 @@ extern "C" { #endif /** - * @brief Linked-list node describing a media asset for an achievement. + * @file achievement.h + * @brief Generic achievement abstraction shared across all integrations. * - * Notes on ownership: - * - In objects created by the copy_* helpers, @c url points to an allocated - * NUL-terminated string that must be freed by @ref free_media_asset. - * - The list is singly-linked via @c next. + * This header provides a platform-agnostic representation of an achievement + * that can be populated from any integration (Xbox Live, RetroAchievements, + * etc.). Platform-specific contract types are kept in their respective + * integration folders (e.g. @c integrations/xbox/contracts/). */ -typedef struct media_asset { - /** Media URL (typically UTF-8). */ - const char *url; - /** Next node in the list, or NULL. */ - struct media_asset *next; -} media_asset_t; /** - * @brief Linked-list node describing a reward associated with an achievement. - * - * Notes on ownership: - * - In objects created by the copy_* helpers, @c value points to an allocated - * NUL-terminated string that must be freed by @ref free_reward. - * - The list is singly-linked via @c next. + * @brief Source platform for an achievement. */ -typedef struct reward { - /** Reward value (the format depends on upstream service). */ - const char *value; - /** Next node in the list, or NULL. */ - struct reward *next; -} reward_t; +typedef enum achievement_source { + ACHIEVEMENT_SOURCE_UNKNOWN = 0, /**< Source not set / unknown. */ + ACHIEVEMENT_SOURCE_XBOX = 1, /**< Achievement originates from Xbox Live. */ + ACHIEVEMENT_SOURCE_RETRO = 2, /**< Achievement originates from RetroAchievements. */ +} achievement_source_t; /** - * @brief Linked-list node describing an achievement and its metadata. + * @brief Generic, platform-agnostic achievement. * - * This type is used as a singly linked list (@c next). Most fields are strings - * coming from the service. When an @c achievement_t is produced by - * @ref copy_achievement, all strings and nested lists are deep-copied. + * Fields are the common denominator across Xbox Live and RetroAchievements. + * All string fields are NUL-terminated and heap-allocated; use + * @ref copy_achievement / @ref free_achievement to manage lifetime. + * + * This type forms a singly-linked list via @c next. * * Ownership: - * - Instances returned by @ref copy_achievement are owned by the caller and must - * be freed with @ref free_achievement. - * - @c media_assets and @c rewards are nested linked lists and are freed by - * @ref free_achievement. + * - Instances returned by @ref copy_achievement are owned by the caller and + * must be freed with @ref free_achievement. */ typedef struct achievement { - /** Achievement id. */ - char *id; - /** Service configuration id. Used for monitoring. */ - char *service_config_id; - /** Display name. */ - char *name; - /** Progress state (service-provided string). */ - char *progress_state; - /** Linked list of media assets associated with this achievement. */ - media_asset_t *media_assets; - /** Whether the achievement is secret. */ - bool is_secret; - /** Description shown when not secret/unlocked. */ - char *description; - /** Description shown when locked/secret. */ - char *locked_description; - /** Linked list of rewards associated with this achievement. */ - reward_t *rewards; - /** Unix timestamp (seconds since epoch) when the achievement was unlocked, or 0 if locked. */ - int64_t unlocked_timestamp; + /** Platform-agnostic string identifier for the achievement. */ + char *id; + /** Human-readable display name. */ + char *name; + /** Description shown when the achievement is unlocked or not secret. */ + char *description; + /** Whether the achievement is secret / hidden. */ + bool is_secret; + /** Point / score value (gamerscore, retro-points, …). */ + int value; /** - * Small icon or tile image URL for the achievement. + * Icon URL (PNG/JPEG). * - * Typically points to a PNG/JPEG hosted by the service. + * Typically the unlocked-badge image. May be NULL if unavailable. */ - char *icon_url; + char *icon_url; + /** Unix timestamp (seconds since epoch) when unlocked; 0 if still locked. */ + int64_t unlocked_timestamp; + /** Which integration produced this achievement. */ + achievement_source_t source; /** Next achievement in the list, or NULL. */ - struct achievement *next; + struct achievement *next; } achievement_t; /** - * @brief Deep-copies a linked list of media assets. - * - * @param media_asset Head of the source list (may be NULL). - * - * @return Head of the newly allocated list, or NULL if @p media_asset is NULL. - * The caller owns the returned list and must free it with - * @ref free_media_asset. - */ -media_asset_t *copy_media_asset(const media_asset_t *media_asset); - -/** - * @brief Frees a linked list of media assets and sets the caller's pointer to NULL. - * - * Safe to call with NULL or with @c *media_asset == NULL. - * - * @param[in,out] media_asset Address of the head pointer to free. - */ -void free_media_asset(media_asset_t **media_asset); - -/** - * @brief Deep-copies a linked list of rewards. - * - * @param reward Head of the source list (may be NULL). - * - * @return Head of the newly allocated list, or NULL if @p reward is NULL. - * The caller owns the returned list and must free it with - * @ref free_reward. - */ -reward_t *copy_reward(const reward_t *reward); - -/** - * @brief Frees a linked list of rewards and sets the caller's pointer to NULL. - * - * Safe to call with NULL or with @c *reward == NULL. - * - * @param[in,out] reward Address of the head pointer to free. - */ -void free_reward(reward_t **reward); - -/** - * @brief Deep-copies a linked list of achievements. - * - * Performs a deep copy of the list, including all strings and nested - * @c media_assets and @c rewards lists. + * @brief Deep-copies a linked list of generic achievements. * * @param achievement Head of the source list (may be NULL). * @@ -138,9 +76,9 @@ void free_reward(reward_t **reward); achievement_t *copy_achievement(const achievement_t *achievement); /** - * @brief Frees a linked list of achievements and sets the caller's pointer to NULL. + * @brief Frees a linked list of generic achievements and sets the caller's pointer to NULL. * - * Frees all strings and nested lists, then frees the list nodes. + * Frees all string fields and list nodes. * Safe to call with NULL or with @c *achievement == NULL. * * @param[in,out] achievement Address of the head pointer to free. @@ -152,18 +90,16 @@ void free_achievement(achievement_t **achievement); * * @param achievements Head of the list (may be NULL). * - * @return Number of nodes in the list. Returns 0 if @p achievements is NULL. + * @return Number of nodes. Returns 0 if @p achievements is NULL. */ int count_achievements(const achievement_t *achievements); /** * @brief Find the most recently unlocked achievement. * - * Iterates through the achievements list and returns the one with the highest - * unlocked_timestamp (most recent unlock). - * * @param achievements Head of the achievements linked list. - * @return Pointer to the most recently unlocked achievement, or NULL if none are unlocked. + * @return Pointer to the achievement with the highest @c unlocked_timestamp, + * or NULL if none are unlocked. */ const achievement_t *find_latest_unlocked_achievement(const achievement_t *achievements); @@ -171,7 +107,7 @@ const achievement_t *find_latest_unlocked_achievement(const achievement_t *achie * @brief Count the number of locked achievements. * * @param achievements Head of the achievements linked list. - * @return Number of locked achievements (unlocked_timestamp == 0). + * @return Number of locked achievements (@c unlocked_timestamp == 0). */ int count_locked_achievements(const achievement_t *achievements); @@ -179,7 +115,7 @@ int count_locked_achievements(const achievement_t *achievements); * @brief Count the number of unlocked achievements. * * @param achievements Head of the achievements linked list. - * @return Number of unlocked achievements (unlocked_timestamp != 0). + * @return Number of unlocked achievements (@c unlocked_timestamp != 0). */ int count_unlocked_achievements(const achievement_t *achievements); @@ -191,6 +127,11 @@ int count_unlocked_achievements(const achievement_t *achievements); */ const achievement_t *get_random_locked_achievement(const achievement_t *achievements); +/** + * @brief Sort achievements in place (unlocked first, then by timestamp descending). + * + * @param achievements Address of the head pointer to sort. + */ void sort_achievements(achievement_t **achievements); #ifdef __cplusplus diff --git a/src/common/achievement_progress.c b/src/common/achievement_progress.c deleted file mode 100644 index f2255ad8..00000000 --- a/src/common/achievement_progress.c +++ /dev/null @@ -1,61 +0,0 @@ -#include "achievement_progress.h" - -#include "memory.h" -#include - -achievement_progress_t *copy_achievement_progress(const achievement_progress_t *achievement_progress) { - - if (!achievement_progress) { - return NULL; - } - - achievement_progress_t *root_copy = NULL; - achievement_progress_t *previous_copy = NULL; - - const achievement_progress_t *current = achievement_progress; - - while (current) { - const achievement_progress_t *next = current->next; - - achievement_progress_t *copy = bzalloc(sizeof(achievement_progress_t)); - copy->id = bstrdup(current->id); - copy->progress_state = bstrdup(current->progress_state); - copy->service_config_id = bstrdup(current->service_config_id); - copy->unlocked_timestamp = current->unlocked_timestamp; - - if (previous_copy) { - previous_copy->next = copy; - } - - previous_copy = copy; - current = next; - - if (!root_copy) { - root_copy = copy; - } - } - - return root_copy; -} - -void free_achievement_progress(achievement_progress_t **achievement_progress) { - - if (!achievement_progress || !*achievement_progress) { - return; - } - - achievement_progress_t *current = *achievement_progress; - - while (current) { - achievement_progress_t *next = current->next; - - free_memory((void **)¤t->id); - free_memory((void **)¤t->progress_state); - free_memory((void **)¤t->service_config_id); - free_memory((void **)¤t); - - current = next; - } - - *achievement_progress = NULL; -} diff --git a/src/common/achievement_progress.h b/src/common/achievement_progress.h deleted file mode 100644 index 5d3f1cff..00000000 --- a/src/common/achievement_progress.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @brief Linked-list node describing an achievement progress entry. - * - * This is a lightweight representation used to track an achievement's progress - * state. It is used as a singly-linked list via @c next. - * - * Ownership: - * - Instances returned by @ref copy_achievement_progress are owned by the caller - * and must be freed with @ref free_achievement_progress. - * - All string fields are deep-copied by the copy helper and freed by the free - * helper. - */ -typedef struct achievement_progress { - /** Service configuration id. */ - const char *service_config_id; - /** Achievement id. */ - const char *id; - /** Progress state. */ - const char *progress_state; - /** Unix timestamp (seconds since epoch) when the achievement was unlocked, or 0 if locked. */ - int64_t unlocked_timestamp; - /** Next progress entry in the list, or NULL. */ - struct achievement_progress *next; -} achievement_progress_t; - -/** - * @brief Deep-copies a linked list of achievement progress entries. - * - * @param progress Head of the source list (may be NULL). - * - * @return Head of the newly allocated list, or NULL if @p progress is NULL. - * The caller owns the returned list and must free it with - * @ref free_achievement_progress. - */ -achievement_progress_t *copy_achievement_progress(const achievement_progress_t *progress); - -/** - * @brief Frees a linked list of achievement progress entries and sets the caller's pointer to NULL. - * - * Safe to call with NULL or with @c *progress == NULL. - * - * @param[in,out] progress Address of the head pointer to free. - */ -void free_achievement_progress(achievement_progress_t **progress); - -#ifdef __cplusplus -} -#endif diff --git a/src/common/game.c b/src/common/game.c index 5eb28b54..3de0df75 100644 --- a/src/common/game.c +++ b/src/common/game.c @@ -9,9 +9,11 @@ game_t *copy_game(const game_t *game) { return NULL; } - game_t *copy = bzalloc(sizeof(game_t)); - copy->id = bstrdup(game->id); - copy->title = bstrdup(game->title); + game_t *copy = bzalloc(sizeof(game_t)); + copy->id = bstrdup(game->id); + copy->title = bstrdup(game->title); + copy->console_name = bstrdup(game->console_name); + copy->cover_url = game->cover_url ? bstrdup(game->cover_url) : NULL; return copy; } @@ -26,6 +28,8 @@ void free_game(game_t **game) { free_memory((void **)¤t->id); free_memory((void **)¤t->title); + free_memory((void **)¤t->console_name); + free_memory((void **)¤t->cover_url); bfree(current); *game = NULL; diff --git a/src/common/game.h b/src/common/game.h index dd880c6d..6a07c41b 100644 --- a/src/common/game.h +++ b/src/common/game.h @@ -19,6 +19,10 @@ typedef struct game { const char *id; /** Human-readable title. */ const char *title; + /** Name of the console / platform the game belongs to. */ + const char *console_name; + /** URL of the game cover image; NULL when unavailable. */ + const char *cover_url; } game_t; /** diff --git a/src/common/gamerscore.c b/src/common/gamerscore.c index 6a135c9e..6dc20a28 100644 --- a/src/common/gamerscore.c +++ b/src/common/gamerscore.c @@ -1,5 +1,5 @@ #include "gamerscore.h" - +#include "integrations/xbox/contracts/xbox_unlocked_achievement.h" #include gamerscore_t *copy_gamerscore(const gamerscore_t *gamerscore) { @@ -11,7 +11,7 @@ gamerscore_t *copy_gamerscore(const gamerscore_t *gamerscore) { gamerscore_t *copy = bzalloc(sizeof(gamerscore_t)); copy->base_value = gamerscore->base_value; - copy->unlocked_achievements = copy_unlocked_achievement(gamerscore->unlocked_achievements); + copy->unlocked_achievements = xbox_copy_unlocked_achievement(gamerscore->unlocked_achievements); return copy; } @@ -23,7 +23,7 @@ void free_gamerscore(gamerscore_t **gamerscore) { } gamerscore_t *current = *gamerscore; - free_unlocked_achievement(¤t->unlocked_achievements); + xbox_free_unlocked_achievement(¤t->unlocked_achievements); bfree(current); *gamerscore = NULL; @@ -37,7 +37,7 @@ int gamerscore_compute(const gamerscore_t *gamerscore) { int total_value = gamerscore->base_value; - const unlocked_achievement_t *current = gamerscore->unlocked_achievements; + const xbox_unlocked_achievement_t *current = gamerscore->unlocked_achievements; while (current) { total_value += current->value; diff --git a/src/common/gamerscore.h b/src/common/gamerscore.h index 18b8e31d..de866b0a 100644 --- a/src/common/gamerscore.h +++ b/src/common/gamerscore.h @@ -1,6 +1,6 @@ #pragma once -#include "common/unlocked_achievement.h" +#include "integrations/xbox/contracts/xbox_unlocked_achievement.h" #ifdef __cplusplus extern "C" { @@ -23,9 +23,9 @@ extern "C" { */ typedef struct gamerscore { /** Base gamerscore value. */ - int base_value; + int base_value; /** Linked list of unlocked achievements used to compute additional score. */ - unlocked_achievement_t *unlocked_achievements; + xbox_unlocked_achievement_t *unlocked_achievements; } gamerscore_t; /** diff --git a/src/common/identity.c b/src/common/identity.c new file mode 100644 index 00000000..f9101786 --- /dev/null +++ b/src/common/identity.c @@ -0,0 +1,84 @@ +#include "common/identity.h" + +#include "common/gamerscore.h" +#include "common/memory.h" + +#include + +/* -------------------------------------------------------------------------- + * Internal helper + * ----------------------------------------------------------------------- */ + +/** + * Allocates and zero-initialises a new identity_t. + */ +static identity_t *alloc_identity(void) { + return bzalloc(sizeof(identity_t)); +} + +/* -------------------------------------------------------------------------- + * Public API + * ----------------------------------------------------------------------- */ + +identity_t *copy_identity(const identity_t *identity) { + if (!identity) { + return NULL; + } + + identity_t *copy = alloc_identity(); + copy->name = identity->name ? bstrdup(identity->name) : NULL; + copy->avatar_url = identity->avatar_url ? bstrdup(identity->avatar_url) : NULL; + copy->score = identity->score; + + return copy; +} + +identity_t *identity_from_xbox(const xbox_identity_t *xbox_identity, const gamerscore_t *gamerscore) { + if (!xbox_identity) { + return NULL; + } + + identity_t *identity = alloc_identity(); + identity->source = IDENTITY_SOURCE_XBOX; + identity->name = xbox_identity->gamertag ? bstrdup(xbox_identity->gamertag) : NULL; + identity->avatar_url = NULL; + + int computed = gamerscore_compute(gamerscore); + identity->score = (computed > 0) ? (uint32_t)computed : 0; + + return identity; +} + +identity_t *identity_from_retro(const retro_user_t *user) { + if (!user) { + return NULL; + } + + identity_t *identity = alloc_identity(); + identity->source = IDENTITY_SOURCE_RETRO; + + /* Prefer display_name; fall back to username when it is empty. */ + const char *name = (user->display_name[0] != '\0') ? user->display_name : user->username; + identity->name = bstrdup(name); + + identity->avatar_url = (user->avatar_url[0] != '\0') ? bstrdup(user->avatar_url) : NULL; + + /* Pick the higher of hardcore and softcore scores. */ + identity->score = (user->score >= user->score_softcore) ? user->score : user->score_softcore; + + return identity; +} + +void free_identity_t(identity_t **identity) { + if (!identity || !*identity) { + return; + } + + identity_t *current = *identity; + + free_memory((void **)¤t->name); + free_memory((void **)¤t->avatar_url); + + bfree(current); + *identity = NULL; +} diff --git a/src/common/identity.h b/src/common/identity.h new file mode 100644 index 00000000..62462ff5 --- /dev/null +++ b/src/common/identity.h @@ -0,0 +1,119 @@ +#pragma once + +#include "common/gamerscore.h" +#include "integrations/xbox/entities/xbox_identity.h" +#include "integrations/retro-achievements/retro_achievements_monitor.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Identifies which integration produced an @c identity_t. + */ +typedef enum { + IDENTITY_SOURCE_XBOX = 0, /**< Xbox Live. */ + IDENTITY_SOURCE_RETRO = 1, /**< RetroAchievements. */ +} identity_source_t; + +/** + * @file identity.h + * @brief Source-agnostic user identity record. + * + * @c identity_t is an intermediary type that normalises user information from + * both Xbox Live and RetroAchievements into a single, uniform representation. + * Consumers that display user information (name, avatar, score) should depend + * on this type rather than on the source-specific types. + * + * Ownership: + * - Instances returned by @ref copy_identity, @ref identity_from_xbox, or + * @ref identity_from_retro are owned by the caller and must be freed with + * @ref free_identity_t. + * - All string fields are heap-allocated (via @c bstrdup / @c bzalloc) and + * freed by @ref free_identity_t. + */ +typedef struct identity { + /** + * Which integration produced this identity. + */ + identity_source_t source; + + /** + * Display name shown to the user. + * + * - Xbox: the gamertag from @c xbox_identity_t. + * - Retro: the @c display_name field from @c retro_user_t (falls back to + * @c username when @c display_name is empty). + */ + char *name; + + /** + * URL of the user's avatar/icon image, or NULL when unavailable. + * + * - Xbox: not provided by @c xbox_identity_t; set to NULL. + * - Retro: the @c avatar_url field from @c retro_user_t. + */ + char *avatar_url; + + /** + * Aggregate score / points. + * + * - Xbox: total gamerscore computed via @ref gamerscore_compute from the + * @c gamerscore_t passed to @ref identity_from_xbox. + * Pass NULL for @p gamerscore to store 0. + * - Retro: @c max(score, score_softcore) from @c retro_user_t, choosing + * the higher of the two values. + */ + uint32_t score; +} identity_t; + +/** + * @brief Creates a deep copy of an identity. + * + * @param identity Source identity to copy (may be NULL). + * + * @return Newly allocated copy, or NULL if @p identity is NULL. + * The caller owns the returned object and must free it with + * @ref free_identity_t. + */ +identity_t *copy_identity(const identity_t *identity); + +/** + * @brief Builds an @c identity_t from an Xbox identity and its gamerscore. + * + * @param xbox_identity Xbox identity supplying the display name. Must not be + * NULL. + * @param gamerscore Gamerscore used to populate @c score. May be NULL, in + * which case @c score is set to 0. + * + * @return Newly allocated @c identity_t. The caller owns it and must free it + * with @ref free_identity_t. Returns NULL if @p xbox_identity is NULL. + */ +identity_t *identity_from_xbox(const xbox_identity_t *xbox_identity, const gamerscore_t *gamerscore); + +/** + * @brief Builds an @c identity_t from a RetroAchievements user record. + * + * The score is set to @c max(user->score, user->score_softcore). + * + * @param user RetroAchievements user record. Must not be NULL. + * + * @return Newly allocated @c identity_t. The caller owns it and must free it + * with @ref free_identity_t. Returns NULL if @p user is NULL. + */ +identity_t *identity_from_retro(const retro_user_t *user); + +/** + * @brief Frees an identity and sets the caller's pointer to NULL. + * + * Safe to call with NULL or with @c *identity == NULL. + * + * @param[in,out] identity Address of the @c identity_t pointer to free. + */ +void free_identity_t(identity_t **identity); + +#ifdef __cplusplus +} +#endif diff --git a/src/common/types.h b/src/common/types.h index cf8be4c6..109b3f8f 100644 --- a/src/common/types.h +++ b/src/common/types.h @@ -13,14 +13,16 @@ */ #include "common/memory.h" #include "common/achievement.h" -#include "common/achievement_progress.h" +#include "integrations/xbox/contracts/xbox_achievement.h" +#include "integrations/xbox/contracts/xbox_achievement_progress.h" +#include "integrations/xbox/contracts/xbox_unlocked_achievement.h" #include "common/device.h" #include "common/game.h" #include "common/gamerscore.h" +#include "common/identity.h" #include "common/token.h" -#include "common/unlocked_achievement.h" -#include "common/xbox_identity.h" -#include "common/xbox_session.h" +#include "integrations/xbox/entities/xbox_identity.h" +#include "integrations/xbox/entities/xbox_session.h" #ifdef __cplusplus extern "C" { diff --git a/src/common/unlocked_achievement.c b/src/common/unlocked_achievement.c deleted file mode 100644 index fccd53f5..00000000 --- a/src/common/unlocked_achievement.c +++ /dev/null @@ -1,58 +0,0 @@ -#include "unlocked_achievement.h" -#include "memory.h" -#include - -unlocked_achievement_t *copy_unlocked_achievement(const unlocked_achievement_t *unlocked_achievement) { - - if (!unlocked_achievement) { - return NULL; - } - - unlocked_achievement_t *root_copy = NULL; - unlocked_achievement_t *previous_copy = NULL; - - const unlocked_achievement_t *current = unlocked_achievement; - - while (current) { - const unlocked_achievement_t *next = current->next; - - unlocked_achievement_t *copy = bzalloc(sizeof(unlocked_achievement_t)); - - copy->id = bstrdup(current->id); - copy->value = current->value; - - if (previous_copy) { - previous_copy->next = copy; - } - - previous_copy = copy; - current = next; - - if (!root_copy) { - root_copy = copy; - } - } - - return root_copy; -} - -void free_unlocked_achievement(unlocked_achievement_t **unlocked_achievement) { - - if (!unlocked_achievement || !*unlocked_achievement) { - return; - } - - unlocked_achievement_t *current = *unlocked_achievement; - - while (current) { - - unlocked_achievement_t *next = current->next; - - free_memory((void **)¤t->id); - free_memory((void **)¤t); - - current = next; - } - - *unlocked_achievement = NULL; -} diff --git a/src/common/unlocked_achievement.h b/src/common/unlocked_achievement.h deleted file mode 100644 index dbf5a65c..00000000 --- a/src/common/unlocked_achievement.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @brief Linked-list node describing an unlocked achievement and its value. - * - * This type is used as a singly-linked list via @c next. - * - * Ownership: - * - Instances returned by @ref copy_unlocked_achievement are owned by the caller - * and must be freed with @ref free_unlocked_achievement. - * - @c id is deep-copied by @ref copy_unlocked_achievement and freed by - * @ref free_unlocked_achievement. - */ -typedef struct unlocked_achievement { - /** Achievement id. */ - const char *id; - /** Gamerscore value contributed by this unlocked achievement. */ - int value; - /** Next node in the list, or NULL. */ - struct unlocked_achievement *next; -} unlocked_achievement_t; - -/** - * @brief Deep-copies a linked list of unlocked achievements. - * - * @param unlocked_achievement Head of the source list (may be NULL). - * - * @return Head of the newly allocated list, or NULL if @p unlocked_achievement - * is NULL. The caller owns the returned list and must free it with - * @ref free_unlocked_achievement. - */ -unlocked_achievement_t *copy_unlocked_achievement(const unlocked_achievement_t *unlocked_achievement); - -/** - * @brief Frees a linked list of unlocked achievements and sets the caller's pointer to NULL. - * - * Safe to call with NULL or with @c *unlocked_achievement == NULL. - * - * @param[in,out] unlocked_achievement Address of the head pointer to free. - */ -void free_unlocked_achievement(unlocked_achievement_t **unlocked_achievement); - -#ifdef __cplusplus -} -#endif diff --git a/src/integrations/monitoring_service.c b/src/integrations/monitoring_service.c new file mode 100644 index 00000000..098266e6 --- /dev/null +++ b/src/integrations/monitoring_service.c @@ -0,0 +1,465 @@ +#include "integrations/monitoring_service.h" + +#include +#include + +#include "integrations/xbox/xbox_monitor.h" +#include "integrations/xbox/xbox_client.h" +#include "integrations/xbox/contracts/xbox_achievement.h" +#include "integrations/retro-achievements/retro_achievements_monitor.h" +#include "common/identity.h" +#include "common/game.h" +#include "common/gamerscore.h" +#include "common/memory.h" +#include "io/state.h" + +/* -------------------------------------------------------------------------- + * Active-identity subscription list + * ----------------------------------------------------------------------- */ + +typedef struct active_identity_subscription { + on_monitoring_active_identity_changed_t callback; + struct active_identity_subscription *next; +} active_identity_subscription_t; + +static active_identity_subscription_t *g_active_identity_subscriptions = NULL; + +static void notify_active_identity(const identity_t *identity) { + active_identity_subscription_t *node = g_active_identity_subscriptions; + while (node) { + node->callback(identity); + node = node->next; + } +} + +static void clear_active_identity_subscriptions(void) { + active_identity_subscription_t *node = g_active_identity_subscriptions; + while (node) { + active_identity_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_active_identity_subscriptions = NULL; +} + +/* -------------------------------------------------------------------------- + * Game-played subscription list + * ----------------------------------------------------------------------- */ + +typedef struct game_played_subscription { + on_monitoring_game_played_t callback; + struct game_played_subscription *next; +} game_played_subscription_t; + +static game_played_subscription_t *g_game_played_subscriptions = NULL; + +static void notify_game_played(const game_t *game) { + game_played_subscription_t *node = g_game_played_subscriptions; + while (node) { + node->callback(game); + node = node->next; + } +} + +static void clear_game_played_subscriptions(void) { + game_played_subscription_t *node = g_game_played_subscriptions; + while (node) { + game_played_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_game_played_subscriptions = NULL; +} + +/* -------------------------------------------------------------------------- + * Module state + * ----------------------------------------------------------------------- */ + +static on_monitoring_connection_changed_t g_connection_changed_callback = NULL; + +static identity_t *g_xbox_identity = NULL; +static identity_t *g_retro_identity = NULL; + +static game_t *g_xbox_game = NULL; +static game_t *g_retro_game = NULL; + +/** + * @brief Tracks which integration produced the most recent game event. + * + * Updated every time on_xbox_game_played or on_retro_game_playing fires. + * Used by get_current_active_identity() to return the identity that belongs + * to the last active game rather than applying a fixed source priority. + */ +static identity_source_t g_last_game_source = IDENTITY_SOURCE_XBOX; + +static const identity_t *get_current_active_identity(void) { + /* When both sources have an active game, the one that reported a game + * most recently takes priority. */ + if (g_last_game_source == IDENTITY_SOURCE_XBOX) { + if (g_xbox_game && g_xbox_identity) + return g_xbox_identity; + if (g_retro_game && g_retro_identity) + return g_retro_identity; + } else { + if (g_retro_game && g_retro_identity) + return g_retro_identity; + if (g_xbox_game && g_xbox_identity) + return g_xbox_identity; + } + + /* No game is active on either source. */ + return NULL; +} + +/** Cached generic achievements for the current game (owned by this module). */ +static achievement_t *g_current_achievements = NULL; + +static on_monitoring_achievements_changed_t g_achievements_changed_callback = NULL; +static on_monitoring_session_ready_t g_session_ready_callback = NULL; + +/** + * @brief Replace the cached achievements list with a new one. + * + * Frees the old list and stores @p new_achievements. Then fires the + * achievements-changed callback if one is registered. + * + * @param new_achievements New list to cache (ownership transferred to this module). + */ +static void replace_current_achievements(achievement_t *new_achievements) { + free_achievement(&g_current_achievements); + g_current_achievements = new_achievements; + + if (g_achievements_changed_callback) + g_achievements_changed_callback(); +} + +/** + * @brief Convert RetroAchievements records to a generic achievement_t linked list. + */ +static achievement_t *retro_to_achievements(const retro_achievement_t *retro, size_t count) { + achievement_t *root = NULL; + achievement_t *previous = NULL; + + for (size_t i = 0; i < count; i++) { + const retro_achievement_t *r = &retro[i]; + + achievement_t *a = bzalloc(sizeof(achievement_t)); + + /* retro_achievement_t.id is a uint32_t – convert to string */ + char id_buf[16]; + snprintf(id_buf, sizeof(id_buf), "%u", r->id); + a->id = bstrdup(id_buf); + + a->name = bstrdup(r->name); + a->description = bstrdup(r->description); + a->icon_url = bstrdup(r->badge_url); + a->is_secret = false; + a->value = (int)r->points; + a->unlocked_timestamp = (strcmp(r->status, "unlocked") == 0) ? 1 : 0; + a->source = ACHIEVEMENT_SOURCE_RETRO; + + if (previous) { + previous->next = a; + } else { + root = a; + } + previous = a; + } + + return root; +} + +/* -------------------------------------------------------------------------- + * Xbox callbacks + * ----------------------------------------------------------------------- */ + +/** + * @brief Compute the current Xbox gamerscore and store it into @p identity. + * + * Centralises the call to get_current_gamerscore() + gamerscore_compute() so + * the same logic is shared between the connection-changed and + * achievements-progressed paths. + */ +static void refresh_xbox_score(identity_t *identity) { + if (!identity) + return; + + const gamerscore_t *gs = get_current_gamerscore(); + identity->score = gs ? (uint32_t)gamerscore_compute(gs) : 0; +} + +static void on_xbox_connection_changed(bool connected, const char *error_message) { + if (connected) { + free_identity_t(&g_xbox_identity); + + xbox_identity_t *xbox = state_get_xbox_identity(); + if (xbox) { + g_xbox_identity = identity_from_xbox(xbox, get_current_gamerscore()); + free_identity(&xbox); + + if (g_xbox_identity) { + free_memory((void **)&g_xbox_identity->avatar_url); + g_xbox_identity->avatar_url = xbox_fetch_gamerpic(); + obs_log(LOG_INFO, + "[MonitoringService] Xbox identity cached: %s (score: %u, avatar: %s)", + g_xbox_identity->name, + g_xbox_identity->score, + g_xbox_identity->avatar_url ? g_xbox_identity->avatar_url : "(none)"); + + /* Identity is cached here but not notified yet β€” a game must + * start before the identity becomes active. The notification + * will fire from on_xbox_game_played. */ + } + } + } else { + free_identity_t(&g_xbox_identity); + free_game(&g_xbox_game); + + /* Xbox disconnected β€” re-evaluate the active identity. If a retro + * game is active it will take over; otherwise NULL is notified. */ + notify_active_identity(get_current_active_identity()); + } + + if (g_connection_changed_callback) + g_connection_changed_callback(connected, error_message); +} + +static void on_xbox_achievements_progressed(const gamerscore_t *gamerscore, + const xbox_achievement_progress_t *progress) { + UNUSED_PARAMETER(gamerscore); + UNUSED_PARAMETER(progress); + + if (!g_xbox_identity) + return; + + refresh_xbox_score(g_xbox_identity); + obs_log(LOG_INFO, "[MonitoringService] Xbox score updated: %u", g_xbox_identity->score); + + /* Refresh the cached generic achievements. */ + replace_current_achievements(xbox_to_achievements(get_current_game_achievements())); + + /* Re-notify so subscribers receive the updated score. */ + if (g_xbox_game) + notify_active_identity(get_current_active_identity()); +} + +static void on_xbox_game_played(const game_t *game) { + free_game(&g_xbox_game); + g_xbox_game = copy_game(game); + + if (g_xbox_game && (!g_xbox_game->cover_url || g_xbox_game->cover_url[0] == '\0')) { + g_xbox_game->cover_url = xbox_get_game_cover(g_xbox_game); + } + + obs_log(LOG_INFO, "[MonitoringService] Xbox game cached: %s", g_xbox_game ? g_xbox_game->title : "(null)"); + + g_last_game_source = IDENTITY_SOURCE_XBOX; + + /* Clear cached achievements β€” they belong to the previous game. */ + replace_current_achievements(NULL); + + /* Notify with the current active identity. g_xbox_identity may be NULL + * here if the game-played event arrives before the connection-changed + * event (which caches the identity), in which case NULL is notified. */ + notify_active_identity(get_current_active_identity()); + + notify_game_played(g_xbox_game); +} + +/** + * @brief Xbox monitor callback invoked when the session is fully ready. + * + * Converts the Xbox achievements to generic form and caches them, then + * notifies subscribers. + */ +static void on_xbox_session_ready(void) { + replace_current_achievements(xbox_to_achievements(get_current_game_achievements())); + + if (g_session_ready_callback) + g_session_ready_callback(); +} + +/* -------------------------------------------------------------------------- + * RetroAchievements callbacks + * ----------------------------------------------------------------------- */ + +static void on_retro_connection_changed(bool connected, const char *error_message) { + if (!connected) + free_identity_t(&g_retro_identity); + + if (g_connection_changed_callback) + g_connection_changed_callback(connected, error_message); +} + +static void on_retro_user(const retro_user_t *user) { + free_identity_t(&g_retro_identity); + g_retro_identity = identity_from_retro(user); + obs_log(LOG_INFO, + "[MonitoringService] Retro identity cached: %s (avatar: %s)", + g_retro_identity ? g_retro_identity->name : "(null)", + g_retro_identity ? g_retro_identity->avatar_url : "(none)"); + + /* The user message may arrive before or after the game message. Only + * notify if a retro game is already active and retro is the last game + * source, otherwise the notification will come from on_retro_game_playing. */ + if (g_retro_game && g_last_game_source == IDENTITY_SOURCE_RETRO) + notify_active_identity(get_current_active_identity()); +} + +static void on_retro_no_user(void) { + free_identity_t(&g_retro_identity); + + /* If retro was the active source, losing the identity means the active + * identity is now NULL (or falls back to Xbox if an Xbox game is active). */ + if (g_last_game_source == IDENTITY_SOURCE_RETRO) + notify_active_identity(get_current_active_identity()); +} + +static void on_retro_game_playing(const retro_game_t *retro_game) { + free_game(&g_retro_game); + + g_retro_game = bzalloc(sizeof(game_t)); + g_retro_game->id = bstrdup(retro_game->game_id); + g_retro_game->title = bstrdup(retro_game->game_name); + g_retro_game->console_name = bstrdup(retro_game->console_name); + g_retro_game->cover_url = bstrdup(retro_game->cover_url); + + obs_log(LOG_INFO, "[MonitoringService] Retro game cached: %s (%s)", g_retro_game->title, g_retro_game->console_name); + + g_last_game_source = IDENTITY_SOURCE_RETRO; + + /* Clear cached achievements β€” they belong to the previous game. */ + replace_current_achievements(NULL); + + /* Retro is now the last game source. get_current_active_identity() will + * return the retro identity if it is cached, or NULL if the user message + * has not arrived yet (it will re-notify via on_retro_user). */ + notify_active_identity(get_current_active_identity()); + + notify_game_played(g_retro_game); +} + +static void on_retro_no_game(void) { + free_game(&g_retro_game); + /* Clear achievements and notify all subscribers: no game is active and + * therefore no identity is active either. */ + replace_current_achievements(NULL); + notify_game_played(NULL); + notify_active_identity(get_current_active_identity()); +} + +/** + * @brief RetroAchievements callback invoked when the achievements' list is received. + */ +static void on_retro_achievements(const retro_achievement_t *achievements, size_t count) { + replace_current_achievements(retro_to_achievements(achievements, count)); + + if (g_session_ready_callback) + g_session_ready_callback(); +} + +/* -------------------------------------------------------------------------- + * Public API + * ----------------------------------------------------------------------- */ + +void monitoring_start(void) { + xbox_subscribe_connected_changed(on_xbox_connection_changed); + xbox_subscribe_achievements_progressed(on_xbox_achievements_progressed); + xbox_subscribe_game_played(on_xbox_game_played); + xbox_subscribe_session_ready(on_xbox_session_ready); + + retro_achievements_subscribe_connection_changed(on_retro_connection_changed); + retro_achievements_subscribe_user(on_retro_user); + retro_achievements_subscribe_no_user(on_retro_no_user); + retro_achievements_subscribe_game_playing(on_retro_game_playing); + retro_achievements_subscribe_no_game(on_retro_no_game); + retro_achievements_subscribe_achievements(on_retro_achievements); + + xbox_monitoring_start(); + retro_achievements_monitor_start(); +} + +void monitoring_stop(void) { + retro_achievements_monitor_stop(); + xbox_monitoring_stop(); + + xbox_subscribe_connected_changed(NULL); + xbox_subscribe_achievements_progressed(NULL); + xbox_subscribe_game_played(NULL); + xbox_subscribe_session_ready(NULL); + + retro_achievements_subscribe_connection_changed(NULL); + retro_achievements_subscribe_user(NULL); + retro_achievements_subscribe_no_user(NULL); + retro_achievements_subscribe_game_playing(NULL); + retro_achievements_subscribe_no_game(NULL); + retro_achievements_subscribe_achievements(NULL); + + free_identity_t(&g_xbox_identity); + free_identity_t(&g_retro_identity); + free_game(&g_xbox_game); + free_game(&g_retro_game); + free_achievement(&g_current_achievements); + + clear_active_identity_subscriptions(); + clear_game_played_subscriptions(); + + g_achievements_changed_callback = NULL; + g_session_ready_callback = NULL; +} + +void monitoring_subscribe_connection_changed(on_monitoring_connection_changed_t callback) { + g_connection_changed_callback = callback; +} + +void monitoring_subscribe_active_identity(on_monitoring_active_identity_changed_t callback) { + if (!callback) { + clear_active_identity_subscriptions(); + return; + } + + active_identity_subscription_t *node = bzalloc(sizeof(active_identity_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[MonitoringService] Failed to allocate active identity subscription"); + return; + } + + node->callback = callback; + node->next = g_active_identity_subscriptions; + g_active_identity_subscriptions = node; + + callback(get_current_active_identity()); +} + +void monitoring_subscribe_game_played(on_monitoring_game_played_t callback) { + if (!callback) { + clear_game_played_subscriptions(); + return; + } + + game_played_subscription_t *node = bzalloc(sizeof(game_played_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[MonitoringService] Failed to allocate game-played subscription"); + return; + } + + node->callback = callback; + node->next = g_game_played_subscriptions; + g_game_played_subscriptions = node; +} + +void monitoring_subscribe_achievements_changed(on_monitoring_achievements_changed_t callback) { + g_achievements_changed_callback = callback; +} + +void monitoring_subscribe_session_ready(on_monitoring_session_ready_t callback) { + g_session_ready_callback = callback; +} + +const identity_t *monitoring_get_current_active_identity(void) { + return get_current_active_identity(); +} + +const achievement_t *monitoring_get_current_game_achievements(void) { + return g_current_achievements; +} diff --git a/src/integrations/monitoring_service.h b/src/integrations/monitoring_service.h new file mode 100644 index 00000000..69f8b7fa --- /dev/null +++ b/src/integrations/monitoring_service.h @@ -0,0 +1,176 @@ +#pragma once + +#include "common/achievement.h" +#include "common/game.h" +#include "common/identity.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file monitoring_service.h + * @brief Unified entry point that starts and stops all integration monitors. + * + * Wraps @ref xbox_monitoring_start / @ref xbox_monitoring_stop and + * @ref retro_achievements_monitor_start / @ref retro_achievements_monitor_stop + * so that callers do not need to depend on each integration directly. + */ + +/** + * @brief Callback invoked when the connection status of any monitor changes. + * + * Mirrors the individual monitor connection-changed signatures so a single + * handler can cover both Xbox and RetroAchievements. + * + * @param connected true if the monitor just connected; false if it + * disconnected. + * @param error_message Human-readable error description when @p connected is + * false and the disconnect was caused by an error; NULL + * for a clean disconnect or a successful connection. + */ +typedef void (*on_monitoring_connection_changed_t)(bool connected, const char *error_message); + +/** + * @brief Callback invoked when the active identity changes. + * + * Fired whenever either integration receives a new-game-playing notification. + * The identity is resolved from whichever source produced the game event. + * May be called with NULL when no identity is available for that source. + * + * @param identity The currently active identity, or NULL if unavailable. + */ +typedef void (*on_monitoring_active_identity_changed_t)(const identity_t *identity); + +/** + * @brief Callback invoked when the current game changes. + * + * Fired by whichever integration detects a new game being played. + * May be called with NULL when no game is active. + * + * @param game The currently played game, or NULL. + */ +typedef void (*on_monitoring_game_played_t)(const game_t *game); + +/** + * @brief Callback invoked when the achievements list for the current game changes. + * + * Fired whenever an integration receives new or updated achievements (e.g. + * after an unlock or when the full list is first fetched). + */ +typedef void (*on_monitoring_achievements_changed_t)(void); + +/** + * @brief Callback invoked when the session is fully ready. + * + * "Ready" means the current game's achievements have been fetched and all + * achievement icons have been prefetched to the local cache. This is the + * appropriate moment to start the achievement display cycle. + */ +typedef void (*on_monitoring_session_ready_t)(void); + +/** + * @brief Start all integration monitors. + * + * Starts the Xbox Live RTA monitor and the RetroAchievements WebSocket + * monitor. Safe to call even if a monitor is already running (each + * individual monitor is idempotent on double-start). + */ +void monitoring_start(void); + +/** + * @brief Stop all integration monitors. + * + * Stops the RetroAchievements WebSocket monitor and the Xbox Live RTA + * monitor. Safe to call when monitors are not running. + */ +void monitoring_stop(void); + +/** + * @brief Subscribe to connection-state change events from any monitor. + * + * The same callback is registered with both the Xbox and RetroAchievements + * monitors. Passing NULL unsubscribes from both. + * + * @param callback Function to invoke on any connection change, or NULL to + * unsubscribe. + */ +void monitoring_subscribe_connection_changed(on_monitoring_connection_changed_t callback); + +/** + * @brief Subscribe to active identity change events. + * + * The callback is fired whenever either integration receives a new-game-playing + * notification, carrying the identity associated with that source. + * Passing NULL unsubscribes. + * + * @param callback Function to invoke when the active identity changes, or NULL + * to unsubscribe. + */ +void monitoring_subscribe_active_identity(on_monitoring_active_identity_changed_t callback); + +/** + * @brief Subscribe to game-played events from any integration. + * + * The callback is fired whenever either integration detects a new game. + * Passing NULL unsubscribes. + * + * @param callback Function to invoke when the current game changes, or NULL + * to unsubscribe. + */ +void monitoring_subscribe_game_played(on_monitoring_game_played_t callback); + +/** + * @brief Subscribe to achievements-changed events from any integration. + * + * The callback is fired whenever the cached achievements list is updated + * (new game, unlock, progress). Passing NULL unsubscribes. + * + * @param callback Function to invoke when achievements change, or NULL + * to unsubscribe. + */ +void monitoring_subscribe_achievements_changed(on_monitoring_achievements_changed_t callback); + +/** + * @brief Subscribe to session-ready events from any integration. + * + * The callback is fired once per game change after all achievement icons have + * been prefetched. Passing NULL unsubscribes. + * + * @param callback Function to invoke when the session is ready, or NULL + * to unsubscribe. + */ +void monitoring_subscribe_session_ready(on_monitoring_session_ready_t callback); + +/** + * @brief Get the currently active identity, if any. + * + * Returns the same identity that would be delivered to active-identity + * subscribers right now. Useful for sources that need to seed their initial + * state at creation time, after the monitor has already connected. + * + * Ownership/lifetime: the returned pointer is owned by the monitoring service + * and may be replaced on the next identity update. Do not free it. + * + * @return The active identity, or NULL if no session is established. + */ +const identity_t *monitoring_get_current_active_identity(void); + +/** + * @brief Get the cached generic achievements list for the current game. + * + * Returns the achievements converted to generic @ref achievement_t form, + * regardless of which integration provided them. + * + * Ownership/lifetime: the returned pointer is owned by the monitoring service + * and may be replaced on the next update. Copy if you need to keep it. + * + * @return Head of the generic achievements linked list, or NULL if unavailable. + */ +const achievement_t *monitoring_get_current_game_achievements(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/integrations/retro-achievements/retro_achievements_monitor.c b/src/integrations/retro-achievements/retro_achievements_monitor.c new file mode 100644 index 00000000..a05dbeae --- /dev/null +++ b/src/integrations/retro-achievements/retro_achievements_monitor.c @@ -0,0 +1,827 @@ +#include "retro_achievements_monitor.h" + +/** + * @file retro_achievements_monitor.c + * @brief WebSocket client implementation for the RetroArch game-state server. + * + * When built with libwebsockets (HAVE_LIBWEBSOCKETS), this module: + * - Connects to the RetroArch WebSocket server on 127.0.0.1:55437. + * - Receives JSON game-state messages and dispatches them to subscribers. + * - Automatically reconnects with exponential back-off on disconnect. + * + * Build variants: + * - If HAVE_LIBWEBSOCKETS is not defined, stub implementations are provided + * that report monitoring is unavailable. + */ + +#include +#include + +#ifdef HAVE_LIBWEBSOCKETS + +#include +#include +#include +#include +#include + +#include "common/types.h" +#include "external/cjson/cJSON.h" + +/* ------------------------------------------------------------------------- + * Constants + * ---------------------------------------------------------------------- */ + +#define RA_WS_PATH "/" +#define RA_PROTOCOL_NAME "retroarch" + +#define RA_LOOP_CHECK_MS 50 +#define RA_INITIAL_RETRY_DELAY_MS 1000 +#define RA_MAX_RETRY_DELAY_MS 60000 + +/* ------------------------------------------------------------------------- + * Subscriber linked-list nodes + * ---------------------------------------------------------------------- */ + +typedef struct game_playing_subscription { + on_retro_game_playing_t callback; + struct game_playing_subscription *next; +} game_playing_subscription_t; + +static game_playing_subscription_t *g_game_playing_subscriptions = NULL; + +typedef struct no_game_subscription { + on_retro_no_game_t callback; + struct no_game_subscription *next; +} no_game_subscription_t; + +static no_game_subscription_t *g_no_game_subscriptions = NULL; + +typedef struct connection_changed_subscription { + on_retro_connection_changed_t callback; + struct connection_changed_subscription *next; +} connection_changed_subscription_t; + +static connection_changed_subscription_t *g_connection_changed_subscriptions = NULL; + +typedef struct achievements_subscription { + on_retro_achievements_t callback; + struct achievements_subscription *next; +} achievements_subscription_t; + +static achievements_subscription_t *g_achievements_subscriptions = NULL; + +typedef struct user_subscription { + on_retro_user_t callback; + struct user_subscription *next; +} user_subscription_t; + +static user_subscription_t *g_user_subscriptions = NULL; + +typedef struct no_user_subscription { + on_retro_no_user_t callback; + struct no_user_subscription *next; +} no_user_subscription_t; + +static no_user_subscription_t *g_no_user_subscriptions = NULL; + +static bool json_item_is_string(const cJSON *item) { + return item != NULL && (item->type & 0xFF) == cJSON_String && item->valuestring != NULL; +} + +/* ------------------------------------------------------------------------- + * Monitor context + * ---------------------------------------------------------------------- */ + +/** + * @brief Internal state for the monitor background thread. + */ +typedef struct monitor_context { + /** libwebsockets event-loop context. */ + struct lws_context *context; + + /** Active WebSocket connection instance. */ + struct lws *wsi; + + /** Background thread running lws_service(). */ + pthread_t thread; + + /** True while the monitor should remain active. */ + bool running; + + /** True once the WebSocket handshake has completed. */ + bool connected; + + /** Last connection status notified to subscribers. */ + bool last_status_notified; + + /** Receive buffer used to accumulate WebSocket fragments. */ + char *rx_buffer; + size_t rx_buffer_size; + size_t rx_buffer_used; +} monitor_context_t; + +static monitor_context_t *g_monitor_context = NULL; + +/* ------------------------------------------------------------------------- + * Notification helpers + * ---------------------------------------------------------------------- */ + +static void notify_game_playing(const retro_game_t *game) { + obs_log(LOG_INFO, "[RetroAchievements] Game playing: %s (%s)", game->game_name, game->game_id); + + game_playing_subscription_t *node = g_game_playing_subscriptions; + while (node) { + node->callback(game); + node = node->next; + } +} + +static void notify_no_game(void) { + obs_log(LOG_INFO, "[RetroAchievements] No game playing"); + + no_game_subscription_t *node = g_no_game_subscriptions; + while (node) { + node->callback(); + node = node->next; + } +} + +static void notify_connection_changed(const char *error_message) { + if (!g_monitor_context) { + return; + } + + if (g_monitor_context->last_status_notified == g_monitor_context->connected) { + return; + } + + obs_log(LOG_INFO, + "[RetroAchievements] Connection changed: %s", + g_monitor_context->connected ? "connected" : "disconnected"); + + if (error_message) { + obs_log(LOG_DEBUG, "[RetroAchievements] Connection error: %s", error_message); + } + + connection_changed_subscription_t *node = g_connection_changed_subscriptions; + while (node) { + node->callback(g_monitor_context->connected, error_message); + node = node->next; + } + + g_monitor_context->last_status_notified = g_monitor_context->connected; +} + +static void notify_achievements(const retro_achievement_t *achievements, size_t count) { + obs_log(LOG_INFO, "[RetroAchievements] Achievements received: %zu", count); + + achievements_subscription_t *node = g_achievements_subscriptions; + while (node) { + node->callback(achievements, count); + node = node->next; + } +} + +static void notify_user(const retro_user_t *user) { + obs_log(LOG_INFO, "[RetroAchievements] User: %s (%s)", user->username, user->display_name); + + user_subscription_t *node = g_user_subscriptions; + while (node) { + node->callback(user); + node = node->next; + } +} + +static void notify_no_user(void) { + obs_log(LOG_INFO, "[RetroAchievements] No user logged in"); + + no_user_subscription_t *node = g_no_user_subscriptions; + while (node) { + node->callback(); + node = node->next; + } +} + +/* ------------------------------------------------------------------------- + * Message parsing + * ---------------------------------------------------------------------- */ + +/** + * @brief Parse and dispatch a complete JSON message from the RetroArch server. + * + * Expected shapes: + * { "type": "game_playing", "game_id": "...", "game_name": "...", + * "console_id": "...", "console_name": "...", + * "cover_url": "..." } + * + * { "type": "no_game" } + * + * { "type": "achievements", + * "items": [ { "id": 1, "name": "...", "points": 5, + * "status": "unlocked", "badge_url": "..." }, ... ] } + * + * { "type": "user", "username": "...", "display_name": "...", + * "score": N, "score_softcore": N, "avatar_url": "..." } + * + * { "type": "no_user" } + */ +static void on_message_received(const char *buffer) { + if (!buffer) { + return; + } + + obs_log(LOG_DEBUG, "[RetroAchievements] Message received: %s", buffer); + + cJSON *root = cJSON_Parse(buffer); + if (!root) { + obs_log(LOG_WARNING, "[RetroAchievements] Failed to parse JSON message"); + return; + } + + cJSON *type_item = cJSON_GetObjectItemCaseSensitive(root, "type"); + if (!json_item_is_string(type_item)) { + obs_log(LOG_WARNING, "[RetroAchievements] Missing or invalid \"type\" field"); + cJSON_Delete(root); + return; + } + + if (strcmp(type_item->valuestring, "game_playing") == 0) { + retro_game_t game; + memset(&game, 0, sizeof(game)); + + cJSON *field; + + field = cJSON_GetObjectItemCaseSensitive(root, "game_id"); + if (json_item_is_string(field)) + strncpy(game.game_id, field->valuestring, sizeof(game.game_id) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "game_name"); + if (json_item_is_string(field)) + strncpy(game.game_name, field->valuestring, sizeof(game.game_name) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "console_id"); + if (json_item_is_string(field)) + strncpy(game.console_id, field->valuestring, sizeof(game.console_id) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "console_name"); + if (json_item_is_string(field)) + strncpy(game.console_name, field->valuestring, sizeof(game.console_name) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "cover_url"); + if (json_item_is_string(field)) + strncpy(game.cover_url, field->valuestring, sizeof(game.cover_url) - 1); + + notify_game_playing(&game); + + } else if (strcmp(type_item->valuestring, "no_game") == 0) { + notify_no_game(); + } else if (strcmp(type_item->valuestring, "achievements") == 0) { + cJSON *items = cJSON_GetObjectItemCaseSensitive(root, "items"); + if (!items || !(items->type & cJSON_Array)) { + obs_log(LOG_WARNING, "[RetroAchievements] \"achievements\" message missing \"items\" array"); + cJSON_Delete(root); + return; + } + + int count = cJSON_GetArraySize(items); + if (count < 0) { + cJSON_Delete(root); + return; + } + + retro_achievement_t *achievements = NULL; + if (count > 0) { + if ((size_t)count > SIZE_MAX / sizeof(retro_achievement_t)) { + obs_log(LOG_ERROR, "[RetroAchievements] Achievement count too large"); + cJSON_Delete(root); + return; + } + achievements = (retro_achievement_t *)bzalloc(sizeof(retro_achievement_t) * (size_t)count); + if (!achievements) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate achievements array"); + cJSON_Delete(root); + return; + } + } + + int idx = 0; + cJSON *item = NULL; + for (item = items->child; item != NULL && idx < count; item = item->next) { + retro_achievement_t *ach = &achievements[idx++]; + + cJSON *field; + + field = cJSON_GetObjectItemCaseSensitive(item, "id"); + if (field != NULL && (field->type & cJSON_Number)) + ach->id = (uint32_t)field->valuedouble; + + field = cJSON_GetObjectItemCaseSensitive(item, "name"); + if (json_item_is_string(field)) + strncpy(ach->name, field->valuestring, sizeof(ach->name) - 1); + + field = cJSON_GetObjectItemCaseSensitive(item, "description"); + if (json_item_is_string(field)) + strncpy(ach->description, field->valuestring, sizeof(ach->description) - 1); + + field = cJSON_GetObjectItemCaseSensitive(item, "points"); + if (field != NULL && (field->type & cJSON_Number)) + ach->points = (uint32_t)field->valuedouble; + + field = cJSON_GetObjectItemCaseSensitive(item, "status"); + if (json_item_is_string(field)) + strncpy(ach->status, field->valuestring, sizeof(ach->status) - 1); + + field = cJSON_GetObjectItemCaseSensitive(item, "badge_url"); + if (json_item_is_string(field)) + strncpy(ach->badge_url, field->valuestring, sizeof(ach->badge_url) - 1); + + obs_log(LOG_INFO, "[RetroAchievements] %d - Achievement: %s (%u points)", idx, ach->name, ach->points); + } + + notify_achievements(achievements, (size_t)count); + bfree(achievements); + + } else if (strcmp(type_item->valuestring, "user") == 0) { + retro_user_t user; + memset(&user, 0, sizeof(user)); + + cJSON *field; + + field = cJSON_GetObjectItemCaseSensitive(root, "username"); + if (json_item_is_string(field)) + strncpy(user.username, field->valuestring, sizeof(user.username) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "display_name"); + if (json_item_is_string(field)) + strncpy(user.display_name, field->valuestring, sizeof(user.display_name) - 1); + + field = cJSON_GetObjectItemCaseSensitive(root, "score"); + if (field != NULL && (field->type & cJSON_Number)) + user.score = (uint32_t)field->valuedouble; + + field = cJSON_GetObjectItemCaseSensitive(root, "score_softcore"); + if (field != NULL && (field->type & cJSON_Number)) + user.score_softcore = (uint32_t)field->valuedouble; + + field = cJSON_GetObjectItemCaseSensitive(root, "avatar_url"); + if (json_item_is_string(field)) + strncpy(user.avatar_url, field->valuestring, sizeof(user.avatar_url) - 1); + + notify_user(&user); + + } else if (strcmp(type_item->valuestring, "no_user") == 0) { + notify_no_user(); + + } else { + obs_log(LOG_DEBUG, "[RetroAchievements] Unknown message type: %s", type_item->valuestring); + } + + cJSON_Delete(root); +} + +/* ------------------------------------------------------------------------- + * Connection event handlers + * ---------------------------------------------------------------------- */ + +static void on_websocket_connected(void) { + g_monitor_context->connected = true; + notify_connection_changed(NULL); +} + +static void on_websocket_disconnected(void) { + g_monitor_context->connected = false; + g_monitor_context->wsi = NULL; + notify_connection_changed(NULL); +} + +static void on_websocket_error(const char *in) { + g_monitor_context->connected = false; + g_monitor_context->wsi = NULL; + notify_connection_changed(in ? in : "Connection error"); +} + +/* ------------------------------------------------------------------------- + * libwebsockets callback + * ---------------------------------------------------------------------- */ + +static int websocket_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { + UNUSED_PARAMETER(user); + + monitor_context_t *ctx = lws_context_user(lws_get_context(wsi)); + + if (!ctx) { + return 0; + } + + switch (reason) { + + case LWS_CALLBACK_CLIENT_ESTABLISHED: + obs_log(LOG_DEBUG, "[RetroAchievements] WebSocket connection established"); + on_websocket_connected(); + break; + + case LWS_CALLBACK_CLIENT_RECEIVE: + obs_log(LOG_DEBUG, "[RetroAchievements] Received %zu bytes", len); + + /* Grow the receive buffer if needed. */ + { + size_t needed = ctx->rx_buffer_used + len + 1; + if (needed > ctx->rx_buffer_size) { + size_t new_size = needed * 2; + char *new_buffer = (char *)brealloc(ctx->rx_buffer, new_size); + if (!new_buffer) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to grow receive buffer"); + return -1; + } + ctx->rx_buffer = new_buffer; + ctx->rx_buffer_size = new_size; + } + + memcpy(ctx->rx_buffer + ctx->rx_buffer_used, in, len); + ctx->rx_buffer_used += len; + + /* Process the message once all fragments have arrived. */ + if (lws_is_final_fragment(wsi)) { + ctx->rx_buffer[ctx->rx_buffer_used] = '\0'; + obs_log(LOG_DEBUG, "[RetroAchievements] Complete message: %s", ctx->rx_buffer); + on_message_received(ctx->rx_buffer); + ctx->rx_buffer_used = 0; + } + } + break; + + case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: + obs_log(LOG_DEBUG, "[RetroAchievements] Connection error: %s", in ? (char *)in : "unknown"); + on_websocket_error(in ? (char *)in : "Connection error"); + break; + + case LWS_CALLBACK_CLIENT_CLOSED: + obs_log(LOG_DEBUG, "[RetroAchievements] Connection closed"); + on_websocket_disconnected(); + break; + + case LWS_CALLBACK_WSI_DESTROY: + ctx->wsi = NULL; + break; + + default: + break; + } + + return 0; +} + +/* ------------------------------------------------------------------------- + * Protocol table + * ---------------------------------------------------------------------- */ + +static const struct lws_protocols protocols[] = { + {RA_PROTOCOL_NAME, websocket_callback, 0, 4096, 0, NULL, 0}, + {NULL, NULL, 0, 0, 0, NULL, 0}, +}; + +/* ------------------------------------------------------------------------- + * Background thread + * ---------------------------------------------------------------------- */ + +static void *monitor_thread(void *arg) { + monitor_context_t *ctx = arg; + + struct lws_context_creation_info info; + memset(&info, 0, sizeof(info)); + + info.port = CONTEXT_PORT_NO_LISTEN; + info.protocols = protocols; + info.user = ctx; + + ctx->context = lws_create_context(&info); + if (!ctx->context) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to create WebSocket context"); + ctx->connected = false; + notify_connection_changed("Failed to create WebSocket context"); + return (void *)1; + } + + struct lws_client_connect_info ccinfo; + memset(&ccinfo, 0, sizeof(ccinfo)); + + ccinfo.context = ctx->context; + ccinfo.address = RETRO_ACHIEVEMENTS_WS_HOST; + ccinfo.port = RETRO_ACHIEVEMENTS_WS_PORT; + ccinfo.path = RA_WS_PATH; + ccinfo.host = ccinfo.address; + ccinfo.origin = ccinfo.address; + ccinfo.protocol = RA_PROTOCOL_NAME; + + obs_log(LOG_DEBUG, + "[RetroAchievements] Connecting to ws://%s:%d%s", + RETRO_ACHIEVEMENTS_WS_HOST, + RETRO_ACHIEVEMENTS_WS_PORT, + RA_WS_PATH); + + ctx->wsi = lws_client_connect_via_info(&ccinfo); + if (!ctx->wsi) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to initiate connection"); + ctx->connected = false; + notify_connection_changed("Failed to initiate connection"); + lws_context_destroy(ctx->context); + ctx->context = NULL; + return (void *)1; + } + + int retry_delay_ms = RA_INITIAL_RETRY_DELAY_MS; + + while (ctx->running && ctx->context) { + lws_service(ctx->context, RA_LOOP_CHECK_MS); + + /* Reconnect if the connection dropped while the monitor is still active. */ + if (ctx->running && !ctx->wsi && ctx->context) { + obs_log(LOG_DEBUG, "[RetroAchievements] Connection lost, retrying in %d ms...", retry_delay_ms); + + int iterations = retry_delay_ms / RA_LOOP_CHECK_MS; + for (int i = 0; i < iterations && ctx->running; i++) { + sleep_ms(RA_LOOP_CHECK_MS); + } + + obs_log(LOG_DEBUG, "[RetroAchievements] Reconnecting..."); + + ctx->wsi = lws_client_connect_via_info(&ccinfo); + + if (!ctx->wsi) { + obs_log(LOG_ERROR, "[RetroAchievements] Reconnect attempt failed"); + retry_delay_ms = retry_delay_ms * 2; + if (retry_delay_ms > RA_MAX_RETRY_DELAY_MS) { + retry_delay_ms = RA_MAX_RETRY_DELAY_MS; + } + } else { + obs_log(LOG_DEBUG, "[RetroAchievements] Connection reestablished"); + retry_delay_ms = RA_INITIAL_RETRY_DELAY_MS; + } + } + } + + if (ctx->context) { + lws_context_destroy(ctx->context); + ctx->context = NULL; + } + + obs_log(LOG_INFO, "[RetroAchievements] Monitor thread stopped"); + + return 0; +} + +/* ------------------------------------------------------------------------- + * Public API + * ---------------------------------------------------------------------- */ + +bool retro_achievements_monitor_start(void) { + bool succeeded = false; + + if (g_monitor_context) { + obs_log(LOG_INFO, "[RetroAchievements] Monitor already running"); + goto done; + } + + g_monitor_context = (monitor_context_t *)bzalloc(sizeof(monitor_context_t)); + if (!g_monitor_context) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate context"); + goto error; + } + + g_monitor_context->running = true; + g_monitor_context->connected = false; + g_monitor_context->last_status_notified = false; + + g_monitor_context->rx_buffer_size = 4096; + g_monitor_context->rx_buffer = (char *)bmalloc(g_monitor_context->rx_buffer_size); + g_monitor_context->rx_buffer_used = 0; + + if (!g_monitor_context->rx_buffer) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate receive buffer"); + goto error; + } + + if (pthread_create(&g_monitor_context->thread, NULL, monitor_thread, g_monitor_context) != 0) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to create background thread"); + goto error; + } + + obs_log(LOG_INFO, "[RetroAchievements] Monitor started"); + succeeded = true; + goto done; + +error: + bfree(g_monitor_context->rx_buffer); + bfree(g_monitor_context); + g_monitor_context = NULL; + +done: + return succeeded; +} + +void retro_achievements_monitor_stop(void) { + if (!g_monitor_context) { + return; + } + + obs_log(LOG_DEBUG, "[RetroAchievements] Stopping monitor"); + + g_monitor_context->running = false; + + if (g_monitor_context->context) { + lws_cancel_service(g_monitor_context->context); + } + + if (g_monitor_context->thread) { + pthread_join(g_monitor_context->thread, NULL); + } + + bfree(g_monitor_context->rx_buffer); + bfree(g_monitor_context); + g_monitor_context = NULL; + + obs_log(LOG_INFO, "[RetroAchievements] Monitor stopped"); +} + +bool retro_achievements_monitor_is_active(void) { + if (!g_monitor_context) { + return false; + } + + return g_monitor_context->running; +} + +void retro_achievements_subscribe_game_playing(on_retro_game_playing_t callback) { + if (!callback) { + game_playing_subscription_t *node = g_game_playing_subscriptions; + while (node) { + game_playing_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_game_playing_subscriptions = NULL; + return; + } + + game_playing_subscription_t *node = bzalloc(sizeof(game_playing_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_game_playing_subscriptions; + g_game_playing_subscriptions = node; +} + +void retro_achievements_subscribe_no_game(on_retro_no_game_t callback) { + if (!callback) { + no_game_subscription_t *node = g_no_game_subscriptions; + while (node) { + no_game_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_no_game_subscriptions = NULL; + return; + } + + no_game_subscription_t *node = bzalloc(sizeof(no_game_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_no_game_subscriptions; + g_no_game_subscriptions = node; +} + +void retro_achievements_subscribe_connection_changed(on_retro_connection_changed_t callback) { + if (!callback) { + connection_changed_subscription_t *node = g_connection_changed_subscriptions; + while (node) { + connection_changed_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_connection_changed_subscriptions = NULL; + return; + } + + connection_changed_subscription_t *node = bzalloc(sizeof(connection_changed_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_connection_changed_subscriptions; + g_connection_changed_subscriptions = node; +} + +void retro_achievements_subscribe_achievements(on_retro_achievements_t callback) { + if (!callback) { + return; + } + + achievements_subscription_t *node = bzalloc(sizeof(achievements_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_achievements_subscriptions; + g_achievements_subscriptions = node; +} + +void retro_achievements_subscribe_user(on_retro_user_t callback) { + if (!callback) { + user_subscription_t *node = g_user_subscriptions; + while (node) { + user_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_user_subscriptions = NULL; + return; + } + + user_subscription_t *node = bzalloc(sizeof(user_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_user_subscriptions; + g_user_subscriptions = node; +} + +void retro_achievements_subscribe_no_user(on_retro_no_user_t callback) { + if (!callback) { + no_user_subscription_t *node = g_no_user_subscriptions; + while (node) { + no_user_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_no_user_subscriptions = NULL; + return; + } + + no_user_subscription_t *node = bzalloc(sizeof(no_user_subscription_t)); + if (!node) { + obs_log(LOG_ERROR, "[RetroAchievements] Failed to allocate subscription node"); + return; + } + + node->callback = callback; + node->next = g_no_user_subscriptions; + g_no_user_subscriptions = node; +} + +#else /* !HAVE_LIBWEBSOCKETS */ + +/* ----------------------------------------------------------------- + * Stub implementations when libwebsockets is not available. + * ----------------------------------------------------------------- */ + +bool retro_achievements_monitor_start(void) { + obs_log(LOG_WARNING, "[RetroAchievements] Built without libwebsockets support – monitor unavailable"); + return false; +} + +void retro_achievements_monitor_stop(void) {} + +bool retro_achievements_monitor_is_active(void) { + return false; +} + +void retro_achievements_subscribe_game_playing(on_retro_game_playing_t callback) { + UNUSED_PARAMETER(callback); +} + +void retro_achievements_subscribe_no_game(on_retro_no_game_t callback) { + UNUSED_PARAMETER(callback); +} + +void retro_achievements_subscribe_connection_changed(on_retro_connection_changed_t callback) { + UNUSED_PARAMETER(callback); +} + +void retro_achievements_subscribe_achievements(on_retro_achievements_t callback) { + UNUSED_PARAMETER(callback); +} + +void retro_achievements_subscribe_user(on_retro_user_t callback) { + UNUSED_PARAMETER(callback); +} + +void retro_achievements_subscribe_no_user(on_retro_no_user_t callback) { + UNUSED_PARAMETER(callback); +} + +#endif /* HAVE_LIBWEBSOCKETS */ diff --git a/src/integrations/retro-achievements/retro_achievements_monitor.h b/src/integrations/retro-achievements/retro_achievements_monitor.h new file mode 100644 index 00000000..18a02292 --- /dev/null +++ b/src/integrations/retro-achievements/retro_achievements_monitor.h @@ -0,0 +1,249 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file retro_achievements_monitor.h + * @brief WebSocket client that connects to the RetroArch game-state server. + * + * This module connects to the RetroArch WebSocket server introduced in + * https://github.com/Octelys/retro-arch/pull/4. That server runs on + * 127.0.0.1 and sends a JSON game-state message whenever a game starts or + * stops. The same message is pushed to every newly connected client. + * + * Message shapes: + * - Game playing : { "type":"game_playing", "game_id":"...", + * "game_name":"...", + * "console_id":"...", "console_name":"...", + * "cover_url":"..." } + * - No game : { "type":"no_game" } + * - Achievements : { "type":"achievements", + * "items":[{ "id":1, "name":"...", "points":5, + * "status":"unlocked", + * "badge_url":"..." }, ...] } + * - User : { "type":"user", "username":"...", + * "display_name":"...", "score":N, + * "score_softcore":N, "avatar_url":"..." } + * - No user : { "type":"no_user" } + * + * Threading: + * - Callbacks may be invoked from the monitor's background thread. + * - Callbacks must return quickly and must not perform OBS graphics + * operations directly (use obs_enter_graphics/obs_leave_graphics or + * schedule work on the OBS main thread). + * + * Ownership/lifetime: + * - Pointers passed to callbacks are owned by the monitor and remain valid + * only for the duration of the callback. Make a deep copy if you need to + * keep the data beyond the callback return. + */ + +/** Default TCP port used by the RetroArch WebSocket server. */ +#define RETRO_ACHIEVEMENTS_WS_PORT 55437 + +/** Default host (loopback only – the server never binds on a public interface). */ +#define RETRO_ACHIEVEMENTS_WS_HOST "127.0.0.1" + +/* ------------------------------------------------------------------------- + * Game-state record + * ---------------------------------------------------------------------- */ + +/** + * @brief Game information received from the RetroArch WebSocket server. + * + * Field sizes mirror those defined in the RetroArch game_state.h header so + * that messages always fit without truncation. + */ +typedef struct { + char game_id[64]; /**< CRC-32 checksum of the ROM as a hex string. */ + char game_name[512]; /**< Base filename of the ROM without extension. */ + char console_id[64]; /**< Short platform identifier (e.g. "snes"). */ + char console_name[256]; /**< Human-readable platform name. */ + char cover_url[512]; /**< URL of the game cover image; empty if absent. */ +} retro_game_t; + +/* ------------------------------------------------------------------------- + * Achievement record + * ---------------------------------------------------------------------- */ + +/** + * @brief A single achievement entry received from the RetroArch WebSocket + * server inside an @c "achievements" message. + */ +typedef struct { + uint32_t id; /**< Numeric achievement ID. */ + char name[256]; /**< Achievement title. */ + char description[1024]; /**< Achievement title. */ + uint32_t points; /**< Point value of the achievement. */ + char status[16]; /**< "unlocked" or "locked". */ + char badge_url[512]; /**< Unlocked badge image URL; empty when absent. */ +} retro_achievement_t; + +/* ------------------------------------------------------------------------- + * User record + * ---------------------------------------------------------------------- */ + +/** + * @brief Logged-in RetroAchievements user information received from the + * RetroArch WebSocket server inside a @c "user" message. + */ +typedef struct { + char username[128]; /**< RA account username. */ + char display_name[128]; /**< Display name (may differ from username). */ + uint32_t score; /**< Hardcore points earned. */ + uint32_t score_softcore; /**< Softcore points earned. */ + char avatar_url[512]; /**< URL of the user's avatar image. */ +} retro_user_t; + +/* ------------------------------------------------------------------------- + * Callback types + * ---------------------------------------------------------------------- */ + +/** + * @brief Invoked when a game is being played. + * + * @param game Non-NULL pointer to the current game information. + * Valid only for the duration of the callback. + */ +typedef void (*on_retro_game_playing_t)(const retro_game_t *game); + +/** + * @brief Invoked when no game is currently being played. + */ +typedef void (*on_retro_no_game_t)(void); + +/** + * @brief Invoked when the connection status changes. + * + * @param connected true if the connection was just established; + * false if it was lost. + * @param error_message Human-readable error description when @p connected is + * false; NULL when the disconnect was clean. + */ +typedef void (*on_retro_connection_changed_t)(bool connected, const char *error_message); + +/** + * @brief Invoked when the achievements list is received. + * + * @param achievements Pointer to an array of @p count achievement records. + * Valid only for the duration of the callback. + * @param count Number of entries in @p achievements. + */ +typedef void (*on_retro_achievements_t)(const retro_achievement_t *achievements, size_t count); + +/** + * @brief Invoked when the logged-in user information is received. + * + * @param user Non-NULL pointer to the current user information. + * Valid only for the duration of the callback. + */ +typedef void (*on_retro_user_t)(const retro_user_t *user); + +/** + * @brief Invoked when no user is logged in. + */ +typedef void (*on_retro_no_user_t)(void); + +/* ------------------------------------------------------------------------- + * Lifecycle + * ---------------------------------------------------------------------- */ + +/** + * @brief Start the RetroArch WebSocket monitor. + * + * Spawns a background thread that connects to the RetroArch WebSocket server + * and begins processing incoming game-state messages. Reconnection is + * attempted automatically with exponential back-off if the connection is + * lost. + * + * @return true if the monitor started successfully; false otherwise. + */ +bool retro_achievements_monitor_start(void); + +/** + * @brief Stop the RetroArch WebSocket monitor. + * + * Signals the background thread to exit, waits for it to finish, and + * releases all resources. Safe to call when the monitor is not running. + */ +void retro_achievements_monitor_stop(void); + +/** + * @brief Check whether the monitor is currently active. + * + * @return true if the background thread is running; false otherwise. + */ +bool retro_achievements_monitor_is_active(void); + +/* ------------------------------------------------------------------------- + * Subscriptions + * ---------------------------------------------------------------------- */ + +/** + * @brief Subscribe to game-playing events. + * + * Ignored if @p callback is NULL. + * + * @param callback Invoked whenever a "game_playing" message is received. + */ +void retro_achievements_subscribe_game_playing(on_retro_game_playing_t callback); + +/** + * @brief Subscribe to no-game events. + * + * Ignored if @p callback is NULL. + * + * @param callback Invoked whenever a "no_game" message is received. + */ +void retro_achievements_subscribe_no_game(on_retro_no_game_t callback); + +/** + * @brief Subscribe to connection-state change events. + * + * Passing NULL clears/unsubscribes the current callback. + * + * @param callback Invoked whenever the WebSocket connection is established or + * lost, or NULL to unsubscribe. + */ +void retro_achievements_subscribe_connection_changed(on_retro_connection_changed_t callback); + +/** + * @brief Subscribe to achievements-list events. + * + * Ignored if @p callback is NULL. + * + * @param callback Invoked whenever an "achievements" message is received, + * passing the full list of achievement records. + */ +void retro_achievements_subscribe_achievements(on_retro_achievements_t callback); + +/** + * @brief Subscribe to user-info events. + * + * Passing NULL clears/unsubscribes the current callback. + * + * @param callback Invoked whenever a "user" message is received with the + * current logged-in user's information, or NULL to unsubscribe. + */ +void retro_achievements_subscribe_user(on_retro_user_t callback); + +/** + * @brief Subscribe to no-user events. + * + * Passing NULL clears/unsubscribes the current callback. + * + * @param callback Invoked whenever a "no_user" message is received, + * indicating that no user is currently logged in, or NULL to + * unsubscribe. + */ +void retro_achievements_subscribe_no_user(on_retro_no_user_t callback); + +#ifdef __cplusplus +} +#endif diff --git a/src/xbox/account_manager.c b/src/integrations/xbox/account_manager.c similarity index 84% rename from src/xbox/account_manager.c rename to src/integrations/xbox/account_manager.c index 5988318d..5c1e2784 100644 --- a/src/xbox/account_manager.c +++ b/src/integrations/xbox/account_manager.c @@ -1,4 +1,4 @@ -#include "xbox/account_manager.h" +#include "integrations/xbox/account_manager.h" #include #include @@ -6,8 +6,8 @@ #include #include "io/state.h" -#include "oauth/xbox-live.h" -#include "xbox/xbox_monitor.h" +#include "integrations/xbox/oauth/xbox-live.h" +#include "integrations/xbox/xbox_monitor.h" static void on_xbox_signed_in(void *data) { UNUSED_PARAMETER(data); @@ -17,7 +17,7 @@ static void on_xbox_signed_in(void *data) { bool xbox_account_sign_in(void) { if (!xbox_live_authenticate(NULL, &on_xbox_signed_in)) { - obs_log(LOG_WARNING, "Xbox sign-in failed"); + obs_log(LOG_WARNING, "[XboxAccount] Sign-in failed"); return false; } diff --git a/src/xbox/account_manager.h b/src/integrations/xbox/account_manager.h similarity index 100% rename from src/xbox/account_manager.h rename to src/integrations/xbox/account_manager.h diff --git a/src/integrations/xbox/contracts/xbox_achievement.c b/src/integrations/xbox/contracts/xbox_achievement.c new file mode 100644 index 00000000..6be4edf3 --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_achievement.c @@ -0,0 +1,344 @@ +#include "integrations/xbox/contracts/xbox_achievement.h" +#include "common/memory.h" +#include "diagnostics/log.h" + +#include +#include +#include + +xbox_media_asset_t *xbox_copy_media_asset(const xbox_media_asset_t *media_asset) { + if (!media_asset) { + return NULL; + } + + xbox_media_asset_t *root_copy = NULL; + xbox_media_asset_t *previous_copy = NULL; + + const xbox_media_asset_t *current = media_asset; + + while (current) { + const xbox_media_asset_t *next = current->next; + + xbox_media_asset_t *copy = bzalloc(sizeof(xbox_media_asset_t)); + copy->url = bstrdup(current->url); + + if (previous_copy) { + previous_copy->next = copy; + } + + previous_copy = copy; + current = next; + + if (!root_copy) { + root_copy = copy; + } + } + + return root_copy; +} + +void xbox_free_media_asset(xbox_media_asset_t **media_asset) { + + if (!media_asset || !*media_asset) { + return; + } + + xbox_media_asset_t *current = *media_asset; + + while (current) { + xbox_media_asset_t *next = current->next; + + free_memory((void **)¤t->url); + free_memory((void **)¤t); + + current = next; + } + + *media_asset = NULL; +} + +xbox_reward_t *xbox_copy_reward(const xbox_reward_t *reward) { + + if (!reward) { + return NULL; + } + + xbox_reward_t *root_copy = NULL; + xbox_reward_t *previous_copy = NULL; + + const xbox_reward_t *current = reward; + + while (current) { + const xbox_reward_t *next = current->next; + + xbox_reward_t *copy = bzalloc(sizeof(xbox_reward_t)); + copy->value = bstrdup(current->value); + + if (previous_copy) { + previous_copy->next = copy; + } + + previous_copy = copy; + current = next; + + if (!root_copy) { + root_copy = copy; + } + } + + return root_copy; +} + +void xbox_free_reward(xbox_reward_t **reward) { + + if (!reward || !*reward) { + return; + } + + xbox_reward_t *current = *reward; + + while (current) { + xbox_reward_t *next = current->next; + + free_memory((void **)¤t->value); + free_memory((void **)¤t); + + current = next; + } + + *reward = NULL; +} + +xbox_achievement_t *xbox_copy_achievement(const xbox_achievement_t *achievement) { + + if (!achievement) { + return NULL; + } + + xbox_achievement_t *root_copy = NULL; + xbox_achievement_t *previous_copy = NULL; + + const xbox_achievement_t *current = achievement; + + while (current) { + const xbox_achievement_t *next = current->next; + + xbox_achievement_t *copy = bzalloc(sizeof(xbox_achievement_t)); + + copy->id = bstrdup(current->id); + copy->description = bstrdup(current->description); + copy->locked_description = bstrdup(current->locked_description); + copy->name = bstrdup(current->name); + copy->progress_state = bstrdup(current->progress_state); + copy->service_config_id = bstrdup(current->service_config_id); + copy->icon_url = bstrdup(current->icon_url); + copy->media_assets = xbox_copy_media_asset(current->media_assets); + copy->rewards = xbox_copy_reward(current->rewards); + copy->is_secret = current->is_secret; + copy->unlocked_timestamp = current->unlocked_timestamp; + + if (previous_copy) { + previous_copy->next = copy; + } + + previous_copy = copy; + current = next; + + if (!root_copy) { + root_copy = copy; + } + } + + return root_copy; +} + +void xbox_free_achievement(xbox_achievement_t **achievement) { + + if (!achievement || !*achievement) { + return; + } + + xbox_achievement_t *current = *achievement; + + while (current) { + xbox_achievement_t *next = current->next; + + free_memory((void **)¤t->service_config_id); + free_memory((void **)¤t->id); + free_memory((void **)¤t->name); + free_memory((void **)¤t->description); + free_memory((void **)¤t->locked_description); + free_memory((void **)¤t->progress_state); + free_memory((void **)¤t->icon_url); + xbox_free_media_asset(¤t->media_assets); + xbox_free_reward(¤t->rewards); + free_memory((void **)¤t); + + current = next; + } + + *achievement = NULL; +} + +int xbox_count_achievements(const xbox_achievement_t *achievements) { + int count = 0; + const xbox_achievement_t *current = achievements; + + while (current) { + count++; + current = current->next; + } + + obs_log(LOG_DEBUG, "Found %d Xbox achievements", count); + + return count; +} + +const xbox_achievement_t *xbox_find_latest_unlocked_achievement(const xbox_achievement_t *achievements) { + const xbox_achievement_t *last_unlocked = NULL; + int64_t latest_timestamp = 0; + + for (const xbox_achievement_t *a = achievements; a != NULL; a = a->next) { + if (a->unlocked_timestamp > latest_timestamp) { + latest_timestamp = a->unlocked_timestamp; + last_unlocked = a; + } + } + + return last_unlocked; +} + +int xbox_count_locked_achievements(const xbox_achievement_t *achievements) { + int count = 0; + + for (const xbox_achievement_t *a = achievements; a != NULL; a = a->next) { + if (a->unlocked_timestamp == 0) { + count++; + } + } + + obs_log(LOG_DEBUG, "Found %d locked Xbox achievements", count); + + return count; +} + +int xbox_count_unlocked_achievements(const xbox_achievement_t *achievements) { + int count = 0; + + for (const xbox_achievement_t *a = achievements; a != NULL; a = a->next) { + if (a->unlocked_timestamp > 0) { + count++; + } + } + + obs_log(LOG_DEBUG, "Found %d unlocked Xbox achievements", count); + + return count; +} + +const xbox_achievement_t *xbox_get_random_locked_achievement(const xbox_achievement_t *achievements) { + const int locked_count = xbox_count_locked_achievements(achievements); + + if (locked_count == 0) { + return NULL; + } + + const int target_index = rand() % locked_count; + int current_index = 0; + + for (const xbox_achievement_t *a = achievements; a != NULL; a = a->next) { + if (a->unlocked_timestamp == 0) { + if (current_index == target_index) { + return a; + } + current_index++; + } + } + + return NULL; +} + +void xbox_sort_achievements(xbox_achievement_t **achievements) { + + if (!achievements || !*achievements || !(*achievements)->next) { + return; + } + + xbox_achievement_t *sorted = NULL; + xbox_achievement_t *current = *achievements; + + /* Insertion sort: take each node from the original list and insert it in sorted order */ + while (current) { + xbox_achievement_t *next = current->next; + + if (!sorted) { + sorted = current; + sorted->next = NULL; + } else { + bool should_insert_before_head = false; + + if (sorted->unlocked_timestamp == 0 && current->unlocked_timestamp != 0) { + should_insert_before_head = true; + } else if (current->unlocked_timestamp != 0 && sorted->unlocked_timestamp != 0 && + current->unlocked_timestamp > sorted->unlocked_timestamp) { + should_insert_before_head = true; + } + + if (should_insert_before_head) { + current->next = sorted; + sorted = current; + } else { + xbox_achievement_t *search = sorted; + while (search->next) { + bool should_insert_here = false; + + if (search->next->unlocked_timestamp == 0 && current->unlocked_timestamp != 0) { + should_insert_here = true; + } else if (current->unlocked_timestamp != 0 && search->next->unlocked_timestamp != 0 && + current->unlocked_timestamp > search->next->unlocked_timestamp) { + should_insert_here = true; + } + + if (should_insert_here) { + break; + } + search = search->next; + } + + current->next = search->next; + search->next = current; + } + } + + current = next; + } + + *achievements = sorted; +} + +achievement_t *xbox_to_achievements(const xbox_achievement_t *xbox) { + + achievement_t *root = NULL; + achievement_t *previous = NULL; + + for (const xbox_achievement_t *x = xbox; x != NULL; x = x->next) { + achievement_t *a = bzalloc(sizeof(achievement_t)); + a->id = bstrdup(x->id); + a->name = bstrdup(x->name); + a->description = bstrdup(x->description); + a->icon_url = bstrdup(x->icon_url); + a->is_secret = x->is_secret; + a->value = (x->rewards && x->rewards->value) ? atoi(x->rewards->value) : 0; + a->unlocked_timestamp = x->unlocked_timestamp; + a->source = ACHIEVEMENT_SOURCE_XBOX; + + if (previous) { + previous->next = a; + } else { + root = a; + } + previous = a; + } + + return root; +} diff --git a/src/integrations/xbox/contracts/xbox_achievement.h b/src/integrations/xbox/contracts/xbox_achievement.h new file mode 100644 index 00000000..0ed6ef04 --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_achievement.h @@ -0,0 +1,217 @@ +#pragma once + +#include "common/achievement.h" +#include "time/time.h" + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Linked-list node describing a media asset for an Xbox achievement. + * + * Notes on ownership: + * - In objects created by the copy_* helpers, @c url points to an allocated + * NUL-terminated string that must be freed by @ref xbox_free_media_asset. + * - The list is singly-linked via @c next. + */ +typedef struct xbox_media_asset { + /** Media URL (typically UTF-8). */ + const char *url; + /** Next node in the list, or NULL. */ + struct xbox_media_asset *next; +} xbox_media_asset_t; + +/** + * @brief Linked-list node describing a reward associated with an Xbox achievement. + * + * Notes on ownership: + * - In objects created by the copy_* helpers, @c value points to an allocated + * NUL-terminated string that must be freed by @ref xbox_free_reward. + * - The list is singly-linked via @c next. + */ +typedef struct xbox_reward { + /** Reward value (the format depends on upstream service). */ + const char *value; + /** Next node in the list, or NULL. */ + struct xbox_reward *next; +} xbox_reward_t; + +/** + * @brief Linked-list node describing an Xbox achievement and its metadata. + * + * This type is used as a singly linked list (@c next). Most fields are strings + * coming from the Xbox Live service. When an @c xbox_achievement_t is produced + * by @ref xbox_copy_achievement, all strings and nested lists are deep-copied. + * + * Ownership: + * - Instances returned by @ref xbox_copy_achievement are owned by the caller + * and must be freed with @ref xbox_free_achievement. + * - @c media_assets and @c rewards are nested linked lists and are freed by + * @ref xbox_free_achievement. + */ +typedef struct xbox_achievement { + /** Achievement id. */ + char *id; + /** Service configuration id. Used for monitoring. */ + char *service_config_id; + /** Display name. */ + char *name; + /** Progress state (service-provided string). */ + char *progress_state; + /** Linked list of media assets associated with this achievement. */ + xbox_media_asset_t *media_assets; + /** Whether the achievement is secret. */ + bool is_secret; + /** Description shown when not secret/unlocked. */ + char *description; + /** Description shown when locked/secret. */ + char *locked_description; + /** Linked list of rewards associated with this achievement. */ + xbox_reward_t *rewards; + /** Unix timestamp (seconds since epoch) when the achievement was unlocked, or 0 if locked. */ + int64_t unlocked_timestamp; + /** + * Small icon or tile image URL for the achievement. + * + * Typically points to a PNG/JPEG hosted by the service. + */ + char *icon_url; + /** Next achievement in the list, or NULL. */ + struct xbox_achievement *next; +} xbox_achievement_t; + +/** + * @brief Deep-copies a linked list of Xbox media assets. + * + * @param media_asset Head of the source list (may be NULL). + * + * @return Head of the newly allocated list, or NULL if @p media_asset is NULL. + * The caller owns the returned list and must free it with + * @ref xbox_free_media_asset. + */ +xbox_media_asset_t *xbox_copy_media_asset(const xbox_media_asset_t *media_asset); + +/** + * @brief Frees a linked list of Xbox media assets and sets the caller's pointer to NULL. + * + * Safe to call with NULL or with @c *media_asset == NULL. + * + * @param[in,out] media_asset Address of the head pointer to free. + */ +void xbox_free_media_asset(xbox_media_asset_t **media_asset); + +/** + * @brief Deep-copies a linked list of Xbox rewards. + * + * @param reward Head of the source list (may be NULL). + * + * @return Head of the newly allocated list, or NULL if @p reward is NULL. + * The caller owns the returned list and must free it with + * @ref xbox_free_reward. + */ +xbox_reward_t *xbox_copy_reward(const xbox_reward_t *reward); + +/** + * @brief Frees a linked list of Xbox rewards and sets the caller's pointer to NULL. + * + * Safe to call with NULL or with @c *reward == NULL. + * + * @param[in,out] reward Address of the head pointer to free. + */ +void xbox_free_reward(xbox_reward_t **reward); + +/** + * @brief Deep-copies a linked list of Xbox achievements. + * + * Performs a deep copy of the list, including all strings and nested + * @c media_assets and @c rewards lists. + * + * @param achievement Head of the source list (may be NULL). + * + * @return Head of the newly allocated list, or NULL if @p achievement is NULL. + * The caller owns the returned list and must free it with + * @ref xbox_free_achievement. + */ +xbox_achievement_t *xbox_copy_achievement(const xbox_achievement_t *achievement); + +/** + * @brief Frees a linked list of Xbox achievements and sets the caller's pointer to NULL. + * + * Frees all strings and nested lists, then frees the list nodes. + * Safe to call with NULL or with @c *achievement == NULL. + * + * @param[in,out] achievement Address of the head pointer to free. + */ +void xbox_free_achievement(xbox_achievement_t **achievement); + +/** + * @brief Counts the number of Xbox achievements in a linked list. + * + * @param achievements Head of the list (may be NULL). + * + * @return Number of nodes in the list. Returns 0 if @p achievements is NULL. + */ +int xbox_count_achievements(const xbox_achievement_t *achievements); + +/** + * @brief Find the most recently unlocked Xbox achievement. + * + * Iterates through the achievements list and returns the one with the highest + * unlocked_timestamp (most recent unlock). + * + * @param achievements Head of the achievements linked list. + * @return Pointer to the most recently unlocked achievement, or NULL if none are unlocked. + */ +const xbox_achievement_t *xbox_find_latest_unlocked_achievement(const xbox_achievement_t *achievements); + +/** + * @brief Count the number of locked Xbox achievements. + * + * @param achievements Head of the achievements linked list. + * @return Number of locked achievements (unlocked_timestamp == 0). + */ +int xbox_count_locked_achievements(const xbox_achievement_t *achievements); + +/** + * @brief Count the number of unlocked Xbox achievements. + * + * @param achievements Head of the achievements linked list. + * @return Number of unlocked achievements (unlocked_timestamp != 0). + */ +int xbox_count_unlocked_achievements(const xbox_achievement_t *achievements); + +/** + * @brief Get a random locked Xbox achievement. + * + * @param achievements Head of the achievements linked list. + * @return Pointer to a random locked achievement, or NULL if none are locked. + */ +const xbox_achievement_t *xbox_get_random_locked_achievement(const xbox_achievement_t *achievements); + +/** + * @brief Sort Xbox achievements in place (unlocked first, then by timestamp descending). + * + * @param achievements Address of the head pointer to sort. + */ +void xbox_sort_achievements(xbox_achievement_t **achievements); + +/** + * @brief Convert a linked list of Xbox achievements to generic achievements. + * + * Maps the common fields from the Xbox contract type to the platform-agnostic + * @ref achievement_t type. The caller owns the returned list and must free it + * with @ref free_achievement. + * + * @param xbox Head of the Xbox achievements list (may be NULL). + * + * @return Head of the newly allocated generic list, or NULL if @p xbox is NULL. + */ +achievement_t *xbox_to_achievements(const xbox_achievement_t *xbox); + +#ifdef __cplusplus +} +#endif diff --git a/src/integrations/xbox/contracts/xbox_achievement_progress.c b/src/integrations/xbox/contracts/xbox_achievement_progress.c new file mode 100644 index 00000000..bf1d2c92 --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_achievement_progress.c @@ -0,0 +1,60 @@ +#include "integrations/xbox/contracts/xbox_achievement_progress.h" +#include "common/memory.h" +#include + +xbox_achievement_progress_t *xbox_copy_achievement_progress(const xbox_achievement_progress_t *achievement_progress) { + + if (!achievement_progress) { + return NULL; + } + + xbox_achievement_progress_t *root_copy = NULL; + xbox_achievement_progress_t *previous_copy = NULL; + + const xbox_achievement_progress_t *current = achievement_progress; + + while (current) { + const xbox_achievement_progress_t *next = current->next; + + xbox_achievement_progress_t *copy = bzalloc(sizeof(xbox_achievement_progress_t)); + copy->id = bstrdup(current->id); + copy->progress_state = bstrdup(current->progress_state); + copy->service_config_id = bstrdup(current->service_config_id); + copy->unlocked_timestamp = current->unlocked_timestamp; + + if (previous_copy) { + previous_copy->next = copy; + } + + previous_copy = copy; + current = next; + + if (!root_copy) { + root_copy = copy; + } + } + + return root_copy; +} + +void xbox_free_achievement_progress(xbox_achievement_progress_t **achievement_progress) { + + if (!achievement_progress || !*achievement_progress) { + return; + } + + xbox_achievement_progress_t *current = *achievement_progress; + + while (current) { + xbox_achievement_progress_t *next = current->next; + + free_memory((void **)¤t->id); + free_memory((void **)¤t->progress_state); + free_memory((void **)¤t->service_config_id); + free_memory((void **)¤t); + + current = next; + } + + *achievement_progress = NULL; +} diff --git a/src/integrations/xbox/contracts/xbox_achievement_progress.h b/src/integrations/xbox/contracts/xbox_achievement_progress.h new file mode 100644 index 00000000..e696bb24 --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_achievement_progress.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Linked-list node describing an Xbox achievement progress entry. + * + * This is a lightweight representation used to track an Xbox achievement's + * progress state. It is used as a singly-linked list via @c next. + * + * Ownership: + * - Instances returned by @ref xbox_copy_achievement_progress are owned by the + * caller and must be freed with @ref xbox_free_achievement_progress. + * - All string fields are deep-copied by the copy helper and freed by the free + * helper. + */ +typedef struct xbox_achievement_progress { + /** Service configuration id. */ + const char *service_config_id; + /** Achievement id. */ + const char *id; + /** Progress state. */ + const char *progress_state; + /** Unix timestamp (seconds since epoch) when the achievement was unlocked, or 0 if locked. */ + int64_t unlocked_timestamp; + /** Next progress entry in the list, or NULL. */ + struct xbox_achievement_progress *next; +} xbox_achievement_progress_t; + +/** + * @brief Deep-copies a linked list of Xbox achievement progress entries. + * + * @param progress Head of the source list (may be NULL). + * + * @return Head of the newly allocated list, or NULL if @p progress is NULL. + * The caller owns the returned list and must free it with + * @ref xbox_free_achievement_progress. + */ +xbox_achievement_progress_t *xbox_copy_achievement_progress(const xbox_achievement_progress_t *progress); + +/** + * @brief Frees a linked list of Xbox achievement progress entries and sets the caller's pointer to NULL. + * + * Safe to call with NULL or with @c *progress == NULL. + * + * @param[in,out] progress Address of the head pointer to free. + */ +void xbox_free_achievement_progress(xbox_achievement_progress_t **progress); + +#ifdef __cplusplus +} +#endif diff --git a/src/integrations/xbox/contracts/xbox_unlocked_achievement.c b/src/integrations/xbox/contracts/xbox_unlocked_achievement.c new file mode 100644 index 00000000..5fc7e08f --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_unlocked_achievement.c @@ -0,0 +1,57 @@ +#include "integrations/xbox/contracts/xbox_unlocked_achievement.h" +#include "common/memory.h" +#include + +xbox_unlocked_achievement_t *xbox_copy_unlocked_achievement(const xbox_unlocked_achievement_t *unlocked_achievement) { + + if (!unlocked_achievement) { + return NULL; + } + + xbox_unlocked_achievement_t *root_copy = NULL; + xbox_unlocked_achievement_t *previous_copy = NULL; + + const xbox_unlocked_achievement_t *current = unlocked_achievement; + + while (current) { + const xbox_unlocked_achievement_t *next = current->next; + + xbox_unlocked_achievement_t *copy = bzalloc(sizeof(xbox_unlocked_achievement_t)); + + copy->id = bstrdup(current->id); + copy->value = current->value; + + if (previous_copy) { + previous_copy->next = copy; + } + + previous_copy = copy; + current = next; + + if (!root_copy) { + root_copy = copy; + } + } + + return root_copy; +} + +void xbox_free_unlocked_achievement(xbox_unlocked_achievement_t **unlocked_achievement) { + + if (!unlocked_achievement || !*unlocked_achievement) { + return; + } + + xbox_unlocked_achievement_t *current = *unlocked_achievement; + + while (current) { + xbox_unlocked_achievement_t *next = current->next; + + free_memory((void **)¤t->id); + free_memory((void **)¤t); + + current = next; + } + + *unlocked_achievement = NULL; +} diff --git a/src/integrations/xbox/contracts/xbox_unlocked_achievement.h b/src/integrations/xbox/contracts/xbox_unlocked_achievement.h new file mode 100644 index 00000000..8790065f --- /dev/null +++ b/src/integrations/xbox/contracts/xbox_unlocked_achievement.h @@ -0,0 +1,49 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Linked-list node describing an unlocked Xbox achievement and its value. + * + * This type is used as a singly-linked list via @c next. + * + * Ownership: + * - Instances returned by @ref xbox_copy_unlocked_achievement are owned by the + * caller and must be freed with @ref xbox_free_unlocked_achievement. + * - @c id is deep-copied by @ref xbox_copy_unlocked_achievement and freed by + * @ref xbox_free_unlocked_achievement. + */ +typedef struct xbox_unlocked_achievement { + /** Achievement id. */ + const char *id; + /** Gamerscore value contributed by this unlocked achievement. */ + int value; + /** Next node in the list, or NULL. */ + struct xbox_unlocked_achievement *next; +} xbox_unlocked_achievement_t; + +/** + * @brief Deep-copies a linked list of unlocked Xbox achievements. + * + * @param unlocked_achievement Head of the source list (may be NULL). + * + * @return Head of the newly allocated list, or NULL if @p unlocked_achievement + * is NULL. The caller owns the returned list and must free it with + * @ref xbox_free_unlocked_achievement. + */ +xbox_unlocked_achievement_t *xbox_copy_unlocked_achievement(const xbox_unlocked_achievement_t *unlocked_achievement); + +/** + * @brief Frees a linked list of unlocked Xbox achievements and sets the caller's pointer to NULL. + * + * Safe to call with NULL or with @c *unlocked_achievement == NULL. + * + * @param[in,out] unlocked_achievement Address of the head pointer to free. + */ +void xbox_free_unlocked_achievement(xbox_unlocked_achievement_t **unlocked_achievement); + +#ifdef __cplusplus +} +#endif diff --git a/src/common/xbox_identity.c b/src/integrations/xbox/entities/xbox_identity.c similarity index 90% rename from src/common/xbox_identity.c rename to src/integrations/xbox/entities/xbox_identity.c index 3c271a04..afdd69e8 100644 --- a/src/common/xbox_identity.c +++ b/src/integrations/xbox/entities/xbox_identity.c @@ -1,6 +1,6 @@ -#include "xbox_identity.h" +#include "integrations/xbox/entities/xbox_identity.h" -#include "memory.h" +#include "common/memory.h" xbox_identity_t *copy_xbox_identity(const xbox_identity_t *identity) { diff --git a/src/common/xbox_identity.h b/src/integrations/xbox/entities/xbox_identity.h similarity index 98% rename from src/common/xbox_identity.h rename to src/integrations/xbox/entities/xbox_identity.h index f796b170..69eaf0a2 100644 --- a/src/common/xbox_identity.h +++ b/src/integrations/xbox/entities/xbox_identity.h @@ -1,6 +1,6 @@ #pragma once -#include "token.h" +#include "common/token.h" #ifdef __cplusplus extern "C" { diff --git a/src/common/xbox_session.c b/src/integrations/xbox/entities/xbox_session.c similarity index 81% rename from src/common/xbox_session.c rename to src/integrations/xbox/entities/xbox_session.c index c9cfc99a..b9e9cf1c 100644 --- a/src/common/xbox_session.c +++ b/src/integrations/xbox/entities/xbox_session.c @@ -1,4 +1,4 @@ -#include "common/xbox_session.h" +#include "integrations/xbox/entities/xbox_session.h" #include @@ -11,7 +11,7 @@ xbox_session_t *copy_xbox_session(const xbox_session_t *session) { xbox_session_t *copy = bzalloc(sizeof(xbox_session_t)); copy->game = copy_game(session->game); copy->gamerscore = copy_gamerscore(session->gamerscore); - copy->achievements = copy_achievement(session->achievements); + copy->achievements = xbox_copy_achievement(session->achievements); return copy; } @@ -26,7 +26,7 @@ void free_xbox_session(xbox_session_t **session) { free_game(¤t->game); free_gamerscore(¤t->gamerscore); - free_achievement(¤t->achievements); + xbox_free_achievement(¤t->achievements); bfree(current); *session = NULL; diff --git a/src/common/xbox_session.h b/src/integrations/xbox/entities/xbox_session.h similarity index 91% rename from src/common/xbox_session.h rename to src/integrations/xbox/entities/xbox_session.h index fbb01240..4c615281 100644 --- a/src/common/xbox_session.h +++ b/src/integrations/xbox/entities/xbox_session.h @@ -1,6 +1,6 @@ #pragma once -#include "common/achievement.h" +#include "integrations/xbox/contracts/xbox_achievement.h" #include "common/game.h" #include "common/gamerscore.h" @@ -22,11 +22,11 @@ extern "C" { */ typedef struct xbox_session { /** Current game information. */ - game_t *game; + game_t *game; /** Gamerscore container (base value + unlocked achievements). */ - gamerscore_t *gamerscore; + gamerscore_t *gamerscore; /** Linked list of achievements for the game. */ - achievement_t *achievements; + xbox_achievement_t *achievements; } xbox_session_t; /** diff --git a/src/oauth/util.c b/src/integrations/xbox/oauth/util.c similarity index 99% rename from src/oauth/util.c rename to src/integrations/xbox/oauth/util.c index 4e382c10..7dafacda 100644 --- a/src/oauth/util.c +++ b/src/integrations/xbox/oauth/util.c @@ -1,4 +1,5 @@ -#include "oauth/util.h" + +#include "integrations/xbox/oauth/util.h" /** * @file util.c diff --git a/src/oauth/util.h b/src/integrations/xbox/oauth/util.h similarity index 100% rename from src/oauth/util.h rename to src/integrations/xbox/oauth/util.h diff --git a/src/oauth/xbox-live.c b/src/integrations/xbox/oauth/xbox-live.c similarity index 92% rename from src/oauth/xbox-live.c rename to src/integrations/xbox/oauth/xbox-live.c index de3d7ab8..83ad8809 100644 --- a/src/oauth/xbox-live.c +++ b/src/integrations/xbox/oauth/xbox-live.c @@ -1,4 +1,4 @@ -#include "oauth/xbox-live.h" +#include "integrations/xbox/oauth/xbox-live.h" /** * @file xbox-live.c @@ -285,17 +285,18 @@ static void complete(authentication_ctx_t *ctx) { static bool retrieve_sisu_token(authentication_ctx_t *ctx) { bool succeeded = false; - uint8_t *signature = NULL; char *signature_b64 = NULL; char *sisu_token_response = NULL; + uint8_t *signature = NULL; char *proof_key = NULL; + cJSON *sisu_token_json = NULL; /* Creates the request */ proof_key = crypto_to_string(ctx->device->keys, false); if (!proof_key) { ctx->result.error_message = "Unable retrieve a sisu token: could not serialize proof key"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -308,7 +309,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { ctx->device_token->value, proof_key); - obs_log(LOG_DEBUG, "Body: %s", json_body); + obs_log(LOG_DEBUG, "[XboxAuth] Sisu token request body: %s", json_body); /* Signs the request */ size_t signature_len = 0; @@ -324,11 +325,11 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!signature_b64) { ctx->result.error_message = "Unable retrieve a sisu token: encoding of the signature failed"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } - obs_log(LOG_DEBUG, "Signature (base64): %s", signature_b64); + obs_log(LOG_DEBUG, "[XboxAuth] Signature (base64): %s", signature_b64); /* Sets up the headers */ char extra_headers[4096]; @@ -340,7 +341,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { "x-xbl-contract-version: 1\r\n", signature_b64); - obs_log(LOG_DEBUG, "Sending request for sisu token: %s", json_body); + obs_log(LOG_DEBUG, "[XboxAuth] Sending sisu token request: %s", json_body); /* * Sends the request @@ -353,15 +354,15 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { goto cleanup; } - obs_log(LOG_DEBUG, "Received response with status code %d: %s", http_code, sisu_token_response); + obs_log(LOG_DEBUG, "[XboxAuth] Sisu token response (status %d): %s", http_code, sisu_token_response); if (http_code < 200 || http_code >= 300) { ctx->result.error_message = "Unable to retrieve a sisu token: received error from the server"; - obs_log(LOG_ERROR, "Unable to retrieve a sisu token: received status code '%d'", http_code); + obs_log(LOG_ERROR, "[XboxAuth] Unable to retrieve sisu token: received status code %d", http_code); goto cleanup; } - cJSON *sisu_token_json = cJSON_Parse(sisu_token_response); + sisu_token_json = cJSON_Parse(sisu_token_response); if (!sisu_token_json) { ctx->result.error_message = "Unable retrieve a sisu token: unable to parse the JSON response"; @@ -373,7 +374,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!token_node) { ctx->result.error_message = "Unable to retrieve a sisu token: no token found"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -382,7 +383,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!xid_node) { ctx->result.error_message = "Unable to retrieve the xid: no value found"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -391,7 +392,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!uhs_node) { ctx->result.error_message = "Unable to retrieve the uhs: no value found"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -400,7 +401,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!not_after_date_node) { ctx->result.error_message = "Unable to retrieve the NotAfter: no value found"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -409,7 +410,7 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!convert_iso8601_utc_to_unix(not_after_date_node->valuestring, &unix_timestamp, &fraction)) { ctx->result.error_message = "Unable retrieve a device token: unable to read the NotAfter date"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -418,17 +419,17 @@ static bool retrieve_sisu_token(authentication_ctx_t *ctx) { if (!gtg_node) { ctx->result.error_message = "Unable to retrieve the gtg: no value found"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } - obs_log(LOG_INFO, "Sisu authentication succeeded!"); + obs_log(LOG_INFO, "[XboxAuth] Sisu authentication succeeded"); - obs_log(LOG_DEBUG, "gtg: %s", gtg_node->valuestring); - obs_log(LOG_DEBUG, "XID: %s", xid_node->valuestring); - obs_log(LOG_DEBUG, "Hash: %s", uhs_node->valuestring); - obs_log(LOG_DEBUG, "Now: %d", now()); - obs_log(LOG_DEBUG, "Expires: %d (%s)", unix_timestamp, not_after_date_node->valuestring); + obs_log(LOG_DEBUG, "[XboxAuth] Gamertag: %s", gtg_node->valuestring); + obs_log(LOG_DEBUG, "[XboxAuth] XID: %s", xid_node->valuestring); + obs_log(LOG_DEBUG, "[XboxAuth] UHS: %s", uhs_node->valuestring); + obs_log(LOG_DEBUG, "[XboxAuth] Now: %d", now()); + obs_log(LOG_DEBUG, "[XboxAuth] Expires: %d (%s)", unix_timestamp, not_after_date_node->valuestring); /* Creates the Xbox identity */ token_t *xbox_token = bzalloc(sizeof(token_t)); @@ -499,7 +500,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { token_t *existing_device_token = state_get_device_token(); if (ctx->allow_cache && existing_device_token) { - obs_log(LOG_INFO, "Using cached device token"); + obs_log(LOG_INFO, "[XboxAuth] Using cached device token"); ctx->device_token = existing_device_token; return retrieve_sisu_token(ctx); } @@ -509,15 +510,16 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { char *device_token_response = NULL; uint8_t *signature = NULL; char *proof_key = NULL; + cJSON *device_token_json = NULL; - obs_log(LOG_INFO, "No device token cached found. Requesting a new device token"); + obs_log(LOG_INFO, "[XboxAuth] No cached device token found, requesting a new one"); /* Builds the device token request */ proof_key = crypto_to_string(ctx->device->keys, false); if (!proof_key) { ctx->result.error_message = "Unable retrieve a device token: could not serialize proof key"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -529,7 +531,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { ctx->device->serial_number, proof_key); - obs_log(LOG_DEBUG, "Device token request is: %s", json_body); + obs_log(LOG_DEBUG, "[XboxAuth] Device token request body: %s", json_body); /* Signs the request */ size_t signature_len = 0; @@ -537,7 +539,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!signature) { ctx->result.error_message = "Unable retrieve a device token: signing failed"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -546,11 +548,11 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!encoded_signature) { ctx->result.error_message = "Unable retrieve a device token: signature encoding failed"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } - obs_log(LOG_DEBUG, "Encoded signature: %s", encoded_signature); + obs_log(LOG_DEBUG, "[XboxAuth] Device token request signature: %s", encoded_signature); /* Creates the headers */ char extra_headers[4096]; @@ -568,7 +570,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!device_token_response) { ctx->result.error_message = "Unable retrieve a device token: server returned no response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); return false; } @@ -582,7 +584,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { } /* Retrieves the device token */ - cJSON *device_token_json = cJSON_Parse(device_token_response); + device_token_json = cJSON_Parse(device_token_response); if (!device_token_json) { ctx->result.error_message = "Unable retrieve a device token: unable to parse the JSON response"; @@ -593,7 +595,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!token_node) { ctx->result.error_message = "Unable retrieve a device token: unable to read the token from the response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -602,7 +604,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!not_after_date_node) { ctx->result.error_message = "Unable retrieve a device token: unable to read the NotAfter field from the response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -611,7 +613,7 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { if (!convert_iso8601_utc_to_unix(not_after_date_node->valuestring, &unix_timestamp, &fraction)) { ctx->result.error_message = "Unable retrieve a device token: unable to read the NotAfter date"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -682,8 +684,9 @@ static bool retrieve_device_token(struct authentication_ctx *ctx) { */ static bool refresh_user_token(authentication_ctx_t *ctx) { - bool succeeded = false; - char *refresh_token_response = NULL; + bool succeeded = false; + char *refresh_token_response = NULL; + cJSON *refresh_token_json = NULL; /* URL-encode both refresh_token and scope to prevent injection attacks * and ensure proper form-urlencoded format. The refresh_token may contain @@ -724,7 +727,7 @@ static bool refresh_user_token(authentication_ctx_t *ctx) { obs_log(LOG_DEBUG, "Response received: %s", refresh_token_response); - cJSON *refresh_token_json = cJSON_Parse(refresh_token_response); + refresh_token_json = cJSON_Parse(refresh_token_response); if (!refresh_token_json) { ctx->result.error_message = "Unable to refresh the user token: unable to parse the JSON response"; @@ -951,8 +954,9 @@ static void poll_for_user_token(authentication_ctx_t *ctx) { */ static void *start_authentication_flow(void *param) { - char *scope_enc = NULL; - char *token_response = NULL; + char *scope_enc = NULL; + char *token_response = NULL; + cJSON *token_json = NULL; authentication_ctx_t *ctx = param; @@ -1009,7 +1013,7 @@ static void *start_authentication_flow(void *param) { obs_log(LOG_DEBUG, "Response received: %s", token_response); - cJSON *token_json = cJSON_Parse(token_response); + token_json = cJSON_Parse(token_response); if (!token_json) { obs_log(LOG_ERROR, "Failed to retrieve the user token: unable to parse the JSON response"); @@ -1030,7 +1034,7 @@ static void *start_authentication_flow(void *param) { if (!device_code_node || strlen(device_code_node->valuestring) == 0) { ctx->result.error_message = "Unable to received a user token: could not parse the device_code from the response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -1038,7 +1042,7 @@ static void *start_authentication_flow(void *param) { if (!interval_node) { ctx->result.error_message = "Unable to received a user token: could not parse the interval from token response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -1047,7 +1051,7 @@ static void *start_authentication_flow(void *param) { if (!expires_in_node) { ctx->result.error_message = "Unable to received a user token: could not parse the expires_in from token response"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -1059,11 +1063,11 @@ static void *start_authentication_flow(void *param) { char verification_uri[4096]; snprintf(verification_uri, sizeof(verification_uri), "%s%s", REGISTER_ENDPOINT, user_code_node->valuestring); - obs_log(LOG_DEBUG, "Open browser for OAuth verification at URL: %s", verification_uri); + obs_log(LOG_DEBUG, "[XboxAuth] Opening browser for OAuth verification: %s", verification_uri); if (!open_url(verification_uri)) { ctx->result.error_message = "Unable to received a user token: could not open the browser"; - obs_log(LOG_ERROR, ctx->result.error_message); + obs_log(LOG_ERROR, "[XboxAuth] %s", ctx->result.error_message); goto cleanup; } @@ -1149,7 +1153,7 @@ bool xbox_live_authenticate(void *data, on_xbox_live_authenticated_t callback) { device_t *device = state_get_device(); if (!device) { - obs_log(LOG_ERROR, "Unable to authenticate: no device identity found"); + obs_log(LOG_ERROR, "[XboxAuth] Unable to authenticate: no device identity found"); return false; } @@ -1234,22 +1238,22 @@ xbox_identity_t *xbox_live_get_identity(void) { xbox_identity_t *identity = state_get_xbox_identity(); if (!identity) { - obs_log(LOG_INFO, "No identity found"); + obs_log(LOG_INFO, "[XboxAuth] No identity found"); return identity; } /* Checks if the Sisu token is expired */ if (!token_is_expired(identity->token)) { - obs_log(LOG_DEBUG, "Token is NOT expired, reusing existing identity"); + obs_log(LOG_DEBUG, "[XboxAuth] Token is not expired, reusing cached identity"); return identity; } - obs_log(LOG_INFO, "Sisu token is expired. Retrieving device information."); + obs_log(LOG_INFO, "[XboxAuth] Sisu token is expired, refreshing"); device_t *device = state_get_device(); if (!device) { - obs_log(LOG_ERROR, "No device found for Xbox token refresh"); + obs_log(LOG_ERROR, "[XboxAuth] No device found for Xbox token refresh"); return false; } diff --git a/src/oauth/xbox-live.h b/src/integrations/xbox/oauth/xbox-live.h similarity index 100% rename from src/oauth/xbox-live.h rename to src/integrations/xbox/oauth/xbox-live.h diff --git a/src/xbox/xbox_client.c b/src/integrations/xbox/xbox_client.c similarity index 77% rename from src/xbox/xbox_client.c rename to src/integrations/xbox/xbox_client.c index cd325e88..df90e2f0 100644 --- a/src/xbox/xbox_client.c +++ b/src/integrations/xbox/xbox_client.c @@ -27,7 +27,7 @@ #include "io/state.h" #include "net/http/http.h" #include "net/json/json.h" -#include "oauth/xbox-live.h" +#include "integrations/xbox/oauth/xbox-live.h" #include "text/parsers.h" #include @@ -114,7 +114,7 @@ char *xbox_get_game_cover(const game_t *game) { char display_request[4096]; snprintf(display_request, sizeof(display_request), XBOX_TITLE_HUB, identity->xid, game->id); - obs_log(LOG_DEBUG, "Display image URL: %s", display_request); + obs_log(LOG_DEBUG, "[XboxClient] Fetching game cover URL: %s", display_request); char headers[4096]; snprintf(headers, @@ -126,8 +126,6 @@ char *xbox_get_game_cover(const game_t *game) { identity->token->value, XBOX_PROFILE_CONTRACT_VERSION); - obs_log(LOG_DEBUG, "Headers: %s", headers); - /* * Sends the request */ @@ -135,16 +133,16 @@ char *xbox_get_game_cover(const game_t *game) { titlehub_response = http_get(display_request, headers, NULL, &http_code); if (http_code < 200 || http_code >= 300) { - obs_log(LOG_ERROR, "Failed to fetch title image: received status code %d", http_code); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch title image: received status code %ld", http_code); goto cleanup; } if (!titlehub_response) { - obs_log(LOG_ERROR, "Failed to fetch title image: received no response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch title image: received no response"); goto cleanup; } - obs_log(LOG_DEBUG, "Response: %s", titlehub_response); + obs_log(LOG_DEBUG, "[XboxClient] Title hub response: %s", titlehub_response); /* * Process the response by trying to get the poster image URL. @@ -178,24 +176,24 @@ char *xbox_get_game_cover(const game_t *game) { } display_image_url = bstrdup_n(image_url_value->valuestring, strlen(image_url_value->valuestring)); - obs_log(LOG_INFO, "Xbox poster image found"); + obs_log(LOG_INFO, "[XboxClient] Game cover (poster/box art) found"); break; } if (!display_image_url) { - obs_log(LOG_INFO, "No Xbox game poster image found: falling back on the display image"); + obs_log(LOG_INFO, "[XboxClient] No poster/box art image found, falling back on display image"); /* No poster image found. Let's see if we can get the display image at least */ cJSON *display_image = cJSONUtils_GetPointer(titlehub_json, XBOX_GAME_COVER_DISPLAY_IMAGE); if (!display_image) { - obs_log(LOG_ERROR, "Failed to fetch title image: displayName property not found"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch title image: displayImage property not found"); goto cleanup; } display_image_url = bstrdup_n(display_image->valuestring, strlen(display_image->valuestring)); - obs_log(LOG_INFO, "Xbox game display image found"); + obs_log(LOG_INFO, "[XboxClient] Game display image found"); } cleanup: @@ -234,7 +232,7 @@ bool xbox_fetch_gamerscore(int64_t *out_gamerscore) { identity->xid, GAMERSCORE_SETTING); - obs_log(LOG_DEBUG, "Body: %s", json_body); + obs_log(LOG_DEBUG, "[XboxClient] Fetching gamerscore for XUID %s", identity->xid); char headers[4096]; snprintf(headers, @@ -245,8 +243,6 @@ bool xbox_fetch_gamerscore(int64_t *out_gamerscore) { identity->token->value, XBOX_PROFILE_CONTRACT_VERSION); - obs_log(LOG_DEBUG, "Headers: %s", headers); - /* * Sends the request */ @@ -254,12 +250,12 @@ bool xbox_fetch_gamerscore(int64_t *out_gamerscore) { profile_settings_response = http_post(XBOX_PROFILE_SETTINGS_ENDPOINT, json_body, headers, &http_code); if (http_code < 200 || http_code >= 300) { - obs_log(LOG_ERROR, "Failed to fetch gamerscore: received status code %d", http_code); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerscore: received status code %ld", http_code); goto cleanup; } if (!profile_settings_response) { - obs_log(LOG_ERROR, "Failed to fetch gamerscore: received no response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerscore: received no response"); goto cleanup; } @@ -269,7 +265,7 @@ bool xbox_fetch_gamerscore(int64_t *out_gamerscore) { gamerscore_text = json_read_string(profile_settings_response, "value"); if (!gamerscore_text) { - obs_log(LOG_ERROR, "Failed to fetch gamerscore: unable to read the value"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerscore: unable to read the value"); goto cleanup; } @@ -309,7 +305,7 @@ char *xbox_fetch_gamerpic() { identity->xid, GAMERPIC_SETTING); - obs_log(LOG_DEBUG, "Profile settings request body: %s", json_body); + obs_log(LOG_DEBUG, "[XboxClient] Fetching gamerpic for XUID %s", identity->xid); char headers[4096]; snprintf(headers, @@ -320,28 +316,26 @@ char *xbox_fetch_gamerpic() { identity->token->value, XBOX_PROFILE_CONTRACT_VERSION); - obs_log(LOG_DEBUG, "Profile settings request headers: %s", headers); - /* Sends the request */ long http_code = 0; profile_response = http_post(XBOX_PROFILE_SETTINGS_ENDPOINT, json_body, headers, &http_code); if (http_code < 200 || http_code >= 300) { - obs_log(LOG_ERROR, "Failed to fetch the user's Gamerpic: received status code %ld", http_code); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerpic: received status code %ld", http_code); goto cleanup; } if (!profile_response) { - obs_log(LOG_ERROR, "Failed to fetch the user's Gamerpic: received no response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerpic: received no response"); goto cleanup; } - obs_log(LOG_DEBUG, "Profile settings response: %s", profile_response); + obs_log(LOG_DEBUG, "[XboxClient] Profile settings response: %s", profile_response); profile_settings_json = cJSON_Parse(profile_response); if (!profile_settings_json) { - obs_log(LOG_ERROR, "Failed to fetch the user's gamerpic: unable to parse the JSON response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch gamerpic: unable to parse the JSON response"); goto cleanup; } @@ -349,7 +343,7 @@ char *xbox_fetch_gamerpic() { cJSON *user_gamerpic_url = cJSONUtils_GetPointer(profile_settings_json, "/profileUsers/0/settings/0/value"); if (!user_gamerpic_url || !user_gamerpic_url->valuestring || user_gamerpic_url->valuestring[0] == '\0') { - obs_log(LOG_INFO, "Failed to fetch the user's gamerpic: no value found."); + obs_log(LOG_INFO, "[XboxClient] Failed to fetch gamerpic: no value found"); goto cleanup; } @@ -359,7 +353,7 @@ char *xbox_fetch_gamerpic() { * Xbox sometimes returns URLs containing "\\u0026" for '&'. Fix it up for curl/http. */ str_replace(gamerpic_url, "u0026", "&"); - obs_log(LOG_DEBUG, "User gamerpic URL is '%s'", gamerpic_url); + obs_log(LOG_DEBUG, "[XboxClient] Gamerpic URL: %s", gamerpic_url); cleanup: free_json_memory((void **)&profile_settings_json); @@ -371,17 +365,18 @@ char *xbox_fetch_gamerpic() { game_t *xbox_get_current_game(void) { - obs_log(LOG_DEBUG, "Retrieving current game"); + obs_log(LOG_DEBUG, "[XboxClient] Retrieving current game"); xbox_identity_t *identity = state_get_xbox_identity(); if (!identity) { - obs_log(LOG_ERROR, "Failed to fetch the current game: no identity found"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch current game: no identity found"); return NULL; } char *presence_response = NULL; game_t *game = NULL; + cJSON *presence_json = NULL; char headers[4096]; snprintf(headers, @@ -392,8 +387,6 @@ game_t *xbox_get_current_game(void) { identity->token->value, XBOX_PROFILE_CONTRACT_VERSION); - obs_log(LOG_DEBUG, "Headers: %s", headers); - /* Sends the request */ char presence_url[512]; snprintf(presence_url, sizeof(presence_url), XBOX_PRESENCE_ENDPOINT, identity->xid); @@ -403,21 +396,21 @@ game_t *xbox_get_current_game(void) { if (http_code < 200 || http_code >= 300) { /* Retry? */ - obs_log(LOG_ERROR, "Failed to fetch the current game: received status code %d", http_code); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch current game: received status code %ld", http_code); goto cleanup; } if (!presence_response) { - obs_log(LOG_ERROR, "Failed to fetch the current game: received no response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch current game: received no response"); goto cleanup; } - obs_log(LOG_DEBUG, "Response: %s", presence_response); + obs_log(LOG_DEBUG, "[XboxClient] Presence response: %s", presence_response); - cJSON *presence_json = cJSON_Parse(presence_response); + presence_json = cJSON_Parse(presence_response); if (!presence_json) { - obs_log(LOG_ERROR, "Failed to fetch the current game: unable to parse the JSON response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch current game: unable to parse the JSON response"); goto cleanup; } @@ -427,12 +420,12 @@ game_t *xbox_get_current_game(void) { cJSON *user_state_value = cJSONUtils_GetPointer(presence_json, user_state_key); if (!user_state_value || strcmp(user_state_value->valuestring, "Offline") == 0) { - obs_log(LOG_INFO, "User is offline at the moment."); + obs_log(LOG_INFO, "[XboxClient] User is offline"); goto cleanup; } - char current_game_title[128]; - char current_game_id[128]; + char current_game_title[128] = ""; + char current_game_id[128] = ""; for (int title_game_index = 0; title_game_index < 10; title_game_index++) { @@ -450,37 +443,41 @@ game_t *xbox_get_current_game(void) { if (!title_game_value || !title_id_value || !state_value) { /* There is nothing more */ - obs_log(LOG_DEBUG, "No more game at %d", title_game_index); + obs_log(LOG_DEBUG, "[XboxClient] No more titles at index %d", title_game_index); break; } if (strcmp(title_game_value->valuestring, "Home") == 0) { - obs_log(LOG_DEBUG, "Skipping home at %d", title_game_index); + obs_log(LOG_DEBUG, "[XboxClient] Skipping home at index %d", title_game_index); continue; } if (strcmp(state_value->valuestring, "Active") != 0) { - obs_log(LOG_DEBUG, "Skipping inactivated game at %d", title_game_index); + obs_log(LOG_DEBUG, "[XboxClient] Skipping inactive title at index %d", title_game_index); continue; } - /* Retrieve the game title and its ID */ - obs_log(LOG_DEBUG, "Game title: %s %s", title_game_value->valuestring, title_id_value->valuestring); + obs_log(LOG_DEBUG, + "[XboxClient] Active title: %s (%s)", + title_game_value->valuestring, + title_id_value->valuestring); snprintf(current_game_title, sizeof(current_game_title), "%s", title_game_value->valuestring); snprintf(current_game_id, sizeof(current_game_id), "%s", title_id_value->valuestring); } if (strlen(current_game_id) == 0) { - obs_log(LOG_INFO, "No game found"); + obs_log(LOG_INFO, "[XboxClient] No active game found"); goto cleanup; } - obs_log(LOG_INFO, "Game is '%s' (%s)", current_game_title, current_game_id); + obs_log(LOG_INFO, "[XboxClient] Current game: %s (%s)", current_game_title, current_game_id); - game = bzalloc(sizeof(game_t)); - game->id = bstrdup(current_game_id); - game->title = bstrdup(current_game_title); + game = bzalloc(sizeof(game_t)); + game->id = bstrdup(current_game_id); + game->title = bstrdup(current_game_title); + /* TODO Figure out if it is Xbox one, Xbox series S, Xbox series X */ + game->console_name = bstrdup("xbox"); cleanup: free_json_memory((void **)&presence_json); @@ -490,7 +487,7 @@ game_t *xbox_get_current_game(void) { return game; } -achievement_t *xbox_get_game_achievements(const game_t *game) { +xbox_achievement_t *xbox_get_game_achievements(const game_t *game) { if (!game) { return NULL; @@ -499,13 +496,13 @@ achievement_t *xbox_get_game_achievements(const game_t *game) { xbox_identity_t *identity = state_get_xbox_identity(); if (!identity) { - obs_log(LOG_ERROR, "Failed to fetch the game's achievements: no identity found"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch achievements: no identity found"); return NULL; } - achievement_t *all_achievements = NULL; - achievement_t *last_achievement = NULL; - char *continuation_token = NULL; + xbox_achievement_t *all_achievements = NULL; + xbox_achievement_t *last_achievement = NULL; + char *continuation_token = NULL; char headers[4096]; snprintf(headers, @@ -516,12 +513,10 @@ achievement_t *xbox_get_game_achievements(const game_t *game) { identity->token->value, XBOX_PROFILE_CONTRACT_VERSION); - obs_log(LOG_DEBUG, "Headers: %s", headers); - /* Pagination loop: keep fetching until no continuation token */ do { - char *response_json = NULL; - achievement_t *page_achievements = NULL; + char *response_json = NULL; + xbox_achievement_t *page_achievements = NULL; /* Build the URL with or without continuation token */ char achievements_url[1024]; @@ -542,17 +537,17 @@ achievement_t *xbox_get_game_achievements(const game_t *game) { response_json = http_get(achievements_url, headers, NULL, &http_code); if (http_code < 200 || http_code >= 300) { - obs_log(LOG_ERROR, "Failed to fetch the games achievements: received status code %ld", http_code); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch achievements: received status code %ld", http_code); FREE(response_json); break; } if (!response_json) { - obs_log(LOG_ERROR, "Failed to fetch the games achievements: received no response"); + obs_log(LOG_ERROR, "[XboxClient] Failed to fetch achievements: received no response"); break; } - obs_log(LOG_DEBUG, "Response length: %zu bytes", strlen(response_json)); + obs_log(LOG_DEBUG, "[XboxClient] Achievement response (%zu bytes): %s", strlen(response_json), response_json); /* Parse achievements from this page */ page_achievements = parse_achievements(response_json); @@ -578,7 +573,7 @@ achievement_t *xbox_get_game_achievements(const game_t *game) { cJSON *paging_info = cJSONUtils_GetPointer(root, "/pagingInfo/continuationToken"); if (paging_info && paging_info->valuestring && paging_info->valuestring[0] != '\0') { continuation_token = bstrdup(paging_info->valuestring); - obs_log(LOG_DEBUG, "Found continuation token, fetching next page..."); + obs_log(LOG_DEBUG, "[XboxClient] Found continuation token, fetching next page..."); } cJSON_Delete(root); } @@ -587,7 +582,10 @@ achievement_t *xbox_get_game_achievements(const game_t *game) { } while (continuation_token); - obs_log(LOG_INFO, "Received %d achievements for game %s", count_achievements(all_achievements), game->title); + obs_log(LOG_INFO, + "[XboxClient] Received %d achievements for game %s", + xbox_count_achievements(all_achievements), + game->title); free_identity(&identity); diff --git a/src/xbox/xbox_client.h b/src/integrations/xbox/xbox_client.h similarity index 95% rename from src/xbox/xbox_client.h rename to src/integrations/xbox/xbox_client.h index c5963992..d2063e69 100644 --- a/src/xbox/xbox_client.h +++ b/src/integrations/xbox/xbox_client.h @@ -36,9 +36,9 @@ game_t *xbox_get_current_game(void); * * @return Head of a newly allocated linked list of achievements, or NULL on * error. The caller owns the returned list and must free it with - * @ref free_achievement. + * @ref xbox_free_achievement. */ -achievement_t *xbox_get_game_achievements(const game_t *game); +xbox_achievement_t *xbox_get_game_achievements(const game_t *game); /** * @brief Fetches a cover image URL for a given game. diff --git a/src/xbox/xbox_monitor.c b/src/integrations/xbox/xbox_monitor.c similarity index 78% rename from src/xbox/xbox_monitor.c rename to src/integrations/xbox/xbox_monitor.c index 85a71fa3..a0e92a2b 100644 --- a/src/xbox/xbox_monitor.c +++ b/src/integrations/xbox/xbox_monitor.c @@ -41,7 +41,7 @@ #include "external/cjson/cJSON.h" #include "io/state.h" -#include "oauth/xbox-live.h" +#include "integrations/xbox/oauth/xbox-live.h" #include @@ -183,13 +183,11 @@ static char *build_authorization_header(const xbox_identity_t *identity) { } static void refresh_token_if_needed() { - obs_log(LOG_DEBUG, "[Monitoring] Checking token"); - if (g_monitoring_context->identity && !token_is_expired(g_monitoring_context->identity->token)) { return; } - obs_log(LOG_DEBUG, "[Monitoring] Refreshing token"); + obs_log(LOG_INFO, "[XboxMonitor] Token expired, refreshing"); free_memory((void **)&g_monitoring_context->auth_token); free_identity(&g_monitoring_context->identity); @@ -197,14 +195,14 @@ static void refresh_token_if_needed() { g_monitoring_context->identity = xbox_live_get_identity(); if (!g_monitoring_context->identity) { - obs_log(LOG_ERROR, "[Monitoring] Failed to refresh the token"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to refresh the token"); return; } /* Replace the cached auth header for future handshakes */ g_monitoring_context->auth_token = build_authorization_header(g_monitoring_context->identity); - obs_log(LOG_INFO, "[Monitoring] Token refreshed"); + obs_log(LOG_INFO, "[XboxMonitor] Token refreshed"); } /** @@ -213,9 +211,9 @@ static void refresh_token_if_needed() { static void notify_game_played(const game_t *game) { if (!game) { - obs_log(LOG_DEBUG, "[Monitoring] No game is being played"); + obs_log(LOG_INFO, "[XboxMonitor] Game stopped"); } else { - obs_log(LOG_INFO, "[Monitoring] Notifying game played: %s (%s)", game->title, game->id); + obs_log(LOG_INFO, "[XboxMonitor] Game played: %s (%s)", game->title, game->id); } game_played_subscription_t *subscriptions = g_game_played_subscriptions; @@ -229,9 +227,11 @@ static void notify_game_played(const game_t *game) { /** * @brief Invoke all registered achievement progressed subscribers. */ -static void notify_achievements_progressed(const achievement_progress_t *achievements_progress) { +static void notify_achievements_progressed(const xbox_achievement_progress_t *achievements_progress) { - obs_log(LOG_INFO, "[Monitoring] Notifying achievements progress: %s", achievements_progress->service_config_id); + obs_log(LOG_INFO, + "[XboxMonitor] Achievement progress received for service config %s", + achievements_progress->service_config_id); achievements_updated_subscription_t *subscription = g_achievements_updated_subscriptions; @@ -251,11 +251,11 @@ static void notify_connection_changed(const char *error_message) { } obs_log(LOG_INFO, - "[Monitoring] Notifying of a connection changed: %s", - g_monitoring_context->connected ? "Connected" : "Not connected"); + "[XboxMonitor] Connection changed: %s", + g_monitoring_context->connected ? "connected" : "disconnected"); if (error_message) { - obs_log(LOG_ERROR, "[Monitoring] Connection error: %s", error_message); + obs_log(LOG_WARNING, "[XboxMonitor] Connection error: %s", error_message); } connection_changed_subscription_t *node = g_connection_changed_subscriptions; @@ -275,7 +275,7 @@ static void notify_connection_changed(const char *error_message) { */ static void notify_session_ready(void) { - obs_log(LOG_INFO, "[Monitoring] Notifying session ready (icons prefetched)"); + obs_log(LOG_INFO, "[XboxMonitor] Session ready"); session_ready_subscription_t *node = g_session_ready_subscriptions; @@ -297,7 +297,7 @@ static bool send_websocket_message(const char *message) { bool succeeded = false; if (!g_monitoring_context || !g_monitoring_context->wsi || !g_monitoring_context->connected) { - obs_log(LOG_ERROR, "[Monitoring] Cannot send message - not connected"); + obs_log(LOG_ERROR, "[XboxMonitor] Cannot send message - not connected"); goto cleanup; } @@ -307,7 +307,7 @@ static bool send_websocket_message(const char *message) { buf = bmalloc(LWS_PRE + len); if (!buf) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate send buffer"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate send buffer"); goto cleanup; } @@ -316,11 +316,11 @@ static bool send_websocket_message(const char *message) { int written = lws_write(g_monitoring_context->wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); if (written < (int)len) { - obs_log(LOG_ERROR, "[Monitoring] Failed to send message (wrote %d of %zu bytes)", written, len); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to send message (wrote %d of %zu bytes)", written, len); goto cleanup; } - obs_log(LOG_DEBUG, "[Monitoring] Sent message: %s", message); + obs_log(LOG_DEBUG, "[XboxMonitor] Sent message: %s", message); succeeded = true; cleanup: @@ -339,19 +339,19 @@ static bool xbox_presence_subscribe() { xbox_identity_t *identity = state_get_xbox_identity(); if (!identity) { - obs_log(LOG_ERROR, "[Monitoring] Invalid Xbox identity for subscription"); + obs_log(LOG_ERROR, "[XboxMonitor] Invalid Xbox identity for subscription"); goto cleanup; } if (!g_monitoring_context || !g_monitoring_context->connected) { - obs_log(LOG_ERROR, "[Monitoring] Cannot subscribe - not connected"); + obs_log(LOG_WARNING, "[XboxMonitor] Cannot subscribe to presence - not connected"); goto cleanup; } char message[512]; snprintf(message, sizeof(message), PRESENCE_SUBSCRIPTION_MESSAGE, SUBSCRIBE, identity->xid); - obs_log(LOG_DEBUG, "[Monitoring] Subscribing for presence changes for XUID %s", identity->xid); + obs_log(LOG_DEBUG, "[XboxMonitor] Subscribing to presence for XUID %s", identity->xid); result = send_websocket_message(message); cleanup: @@ -367,19 +367,19 @@ static bool xbox_presence_subscribe() { */ static bool xbox_presence_unsubscribe(const char *subscription_id) { if (!subscription_id || !*subscription_id) { - obs_log(LOG_ERROR, "[Monitoring] Invalid subscription ID for unsubscribe"); + obs_log(LOG_WARNING, "[XboxMonitor] Cannot unsubscribe from presence - no subscription ID"); return false; } if (!g_monitoring_context || !g_monitoring_context->connected) { - obs_log(LOG_ERROR, "[Monitoring] Cannot unsubscribe - not connected"); + obs_log(LOG_WARNING, "[XboxMonitor] Cannot unsubscribe from presence - not connected"); return false; } char message[256]; snprintf(message, sizeof(message), "[%d,1,\"%s\"]", UNSUBSCRIBE, subscription_id); - obs_log(LOG_DEBUG, "[Monitoring] Unsubscribing from %s", subscription_id); + obs_log(LOG_DEBUG, "[XboxMonitor] Unsubscribing from presence channel %s", subscription_id); return send_websocket_message(message); } @@ -389,26 +389,26 @@ static bool xbox_presence_unsubscribe(const char *subscription_id) { static bool xbox_achievements_progress_subscribe(const xbox_session_t *session) { if (!session) { - obs_log(LOG_ERROR, "[Monitoring] No session specified"); + obs_log(LOG_ERROR, "[XboxMonitor] No session specified"); return false; } if (!session->game) { - obs_log(LOG_DEBUG, "[Monitoring] No game being played, skipping achievement subscription"); + obs_log(LOG_DEBUG, "[XboxMonitor] No game being played, skipping achievement subscription"); return false; } - const achievement_t *achievements = session->achievements; + const xbox_achievement_t *achievements = session->achievements; if (!achievements) { - obs_log(LOG_ERROR, "[Monitoring] No achievements specified"); + obs_log(LOG_WARNING, "[XboxMonitor] No achievements available for current game, skipping subscription"); return false; } const char *service_config_id = achievements->service_config_id; if (!g_monitoring_context || !g_monitoring_context->connected) { - obs_log(LOG_ERROR, "[Monitoring] Cannot subscribe - not connected"); + obs_log(LOG_WARNING, "[XboxMonitor] Cannot subscribe to achievements - not connected"); return false; } @@ -420,10 +420,7 @@ static bool xbox_achievements_progress_subscribe(const xbox_session_t *session) g_monitoring_context->identity->xid, service_config_id); - obs_log(LOG_DEBUG, - "[Monitoring] Subscribing for achievement updates for service config id %s (XUID %s)", - service_config_id, - g_monitoring_context->identity->xid); + obs_log(LOG_DEBUG, "[XboxMonitor] Subscribing to achievement updates for service config %s", service_config_id); return send_websocket_message(message); } @@ -434,24 +431,24 @@ static bool xbox_achievements_progress_subscribe(const xbox_session_t *session) static bool xbox_achievements_progress_unsubscribe(const xbox_session_t *session) { if (!session) { - obs_log(LOG_ERROR, "[Monitoring] No session specified"); + obs_log(LOG_ERROR, "[XboxMonitor] No session specified"); return false; } if (!session->game) { - obs_log(LOG_DEBUG, "[Monitoring] No game being played, skipping achievement unsubscription"); + obs_log(LOG_DEBUG, "[XboxMonitor] No game being played, skipping achievement unsubscription"); return false; } - const achievement_t *achievements = session->achievements; + const xbox_achievement_t *achievements = session->achievements; if (!achievements) { - obs_log(LOG_ERROR, "[Monitoring] No achievements specified"); + obs_log(LOG_WARNING, "[XboxMonitor] No achievements available for current game, skipping unsubscription"); return false; } if (!g_monitoring_context || !g_monitoring_context->connected) { - obs_log(LOG_ERROR, "[Monitoring] Cannot subscribe - not connected"); + obs_log(LOG_WARNING, "[XboxMonitor] Cannot unsubscribe from achievements - not connected"); return false; } @@ -464,9 +461,8 @@ static bool xbox_achievements_progress_unsubscribe(const xbox_session_t *session achievements->service_config_id); obs_log(LOG_DEBUG, - "[Monitoring] Unsubscribing from achievement updates for service config id %s (XUID %s)", - achievements->service_config_id, - g_monitoring_context->identity->xid); + "[XboxMonitor] Unsubscribing from achievement updates for service config %s", + achievements->service_config_id); return send_websocket_message(message); } @@ -511,7 +507,7 @@ static void on_game_update_received(game_t *game) { /** * @brief Handle a parsed achievement progress message. */ -static void on_achievement_progress_received(const achievement_progress_t *progress) { +static void on_achievement_progress_received(const xbox_achievement_progress_t *progress) { if (!progress) { /* No change */ @@ -544,6 +540,13 @@ static void on_websocket_connected() { xbox_presence_subscribe(); + /* Notify subscribers that we are connected BEFORE fetching the current + * game. This ensures that monitoring_service.c has g_xbox_identity set + * (via on_xbox_connection_changed) when the subsequent game-played and + * session-ready notifications arrive, so the gamertag source never + * briefly shows "Not connected". */ + notify_connection_changed(NULL); + /* Immediately retrieves the game being played, if any */ game_t *current_game = xbox_get_current_game(); xbox_change_game(current_game); @@ -553,8 +556,6 @@ static void on_websocket_connected() { if (g_current_session.game != NULL) { xbox_achievements_progress_subscribe(&g_current_session); } - - notify_connection_changed(NULL); } /** @@ -593,6 +594,7 @@ static void on_buffer_received(const char *buffer) { cJSON *presence_item = NULL; game_t *game = NULL; + char *game_id = NULL; char *message = NULL; cJSON *root = NULL; @@ -600,7 +602,7 @@ static void on_buffer_received(const char *buffer) { return; } - obs_log(LOG_DEBUG, "[Monitoring] New buffer received %s", buffer); + obs_log(LOG_DEBUG, "[XboxMonitor] Message received (%zu bytes)", strlen(buffer)); /* Parse the buffer [X,X,X] */ root = cJSON_Parse(buffer); @@ -613,35 +615,47 @@ static void on_buffer_received(const char *buffer) { presence_item = cJSON_GetArrayItem(root, 2); if (!presence_item) { - obs_log(LOG_WARNING, "[Monitoring] No presence item found"); + obs_log(LOG_DEBUG, "[XboxMonitor] No payload at index 2, skipping"); goto cleanup; } message = cJSON_PrintUnformatted(presence_item); if (strlen(message) < 5) { - obs_log(LOG_DEBUG, "[Monitoring] No message"); + obs_log(LOG_DEBUG, "[XboxMonitor] Message payload too short, skipping"); goto cleanup; } if (is_presence_message(message)) { - obs_log(LOG_DEBUG, "[Monitoring] Message is a presence message"); - game = parse_game(message); + + obs_log(LOG_DEBUG, "[XboxMonitor] Message is a presence message"); + + /* Parse the rich presence information however, we only want the game ID since + * the presence game does not provide the game title; just a rich presence text */ + game_id = parse_presence_game_id(message); + + if (g_current_session.game != NULL && game_id != NULL && strcasecmp(game_id, g_current_session.game->id) == 0) { + obs_log(LOG_DEBUG, "[XboxMonitor] Game ID has not changed: %s", game_id); + goto cleanup; + } + + game = xbox_get_current_game(); on_game_update_received(game); goto cleanup; } if (is_achievement_message(message)) { - obs_log(LOG_DEBUG, "[Monitoring] Message is an achievement message"); - achievement_progress_t *progress = parse_achievement_progress(message); + obs_log(LOG_DEBUG, "[XboxMonitor] Message is an achievement message"); + xbox_achievement_progress_t *progress = parse_achievement_progress(message); on_achievement_progress_received(progress); - free_achievement_progress(&progress); + xbox_free_achievement_progress(&progress); } cleanup: if (message) { free(message); } + free_memory((void **)&game_id); free_game(&game); free_json_memory((void **)&root); } @@ -673,29 +687,27 @@ static int websocket_callback(struct lws *wsi, enum lws_callback_reasons reason, (int)strlen(ctx->auth_token), p, end)) { - obs_log(LOG_ERROR, "[Monitoring] Failed to add Authorization header"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to add Authorization header"); return -1; } - obs_log(LOG_DEBUG, "[Monitoring] Added Authorization header to handshake"); + obs_log(LOG_DEBUG, "[XboxMonitor] Added Authorization header to handshake"); } break; case LWS_CALLBACK_CLIENT_ESTABLISHED: - obs_log(LOG_DEBUG, "[Monitoring] WebSocket connection established"); + obs_log(LOG_INFO, "[XboxMonitor] WebSocket connection established"); on_websocket_connected(); break; - case LWS_CALLBACK_CLIENT_RECEIVE: - obs_log(LOG_DEBUG, "[Monitoring] Received %zu bytes", len); - + case LWS_CALLBACK_CLIENT_RECEIVE: { /* Ensure the buffer has enough space */ size_t needed = ctx->rx_buffer_used + len + 1; if (needed > ctx->rx_buffer_size) { size_t new_size = needed * 2; char *new_buffer = (char *)brealloc(ctx->rx_buffer, new_size); if (!new_buffer) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate receive buffer"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate receive buffer"); return -1; } ctx->rx_buffer = new_buffer; @@ -710,7 +722,7 @@ static int websocket_callback(struct lws *wsi, enum lws_callback_reasons reason, if (lws_is_final_fragment(wsi)) { ctx->rx_buffer[ctx->rx_buffer_used] = '\0'; - obs_log(LOG_DEBUG, "[Monitoring] Complete message received: %s", ctx->rx_buffer); + obs_log(LOG_DEBUG, "[XboxMonitor] Dispatching message: %s", ctx->rx_buffer); on_buffer_received(ctx->rx_buffer); @@ -718,6 +730,7 @@ static int websocket_callback(struct lws *wsi, enum lws_callback_reasons reason, ctx->rx_buffer_used = 0; } break; + } case LWS_CALLBACK_CLIENT_RECEIVE_PONG: /* @@ -732,12 +745,12 @@ static int websocket_callback(struct lws *wsi, enum lws_callback_reasons reason, refresh_token_if_needed(); break; case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: - obs_log(LOG_ERROR, "[Monitoring] Connection error: %s", in ? (char *)in : "unknown"); + obs_log(LOG_ERROR, "[XboxMonitor] Connection error: %s", in ? (char *)in : "unknown"); on_websocket_error(in ? (char *)in : "Connection error"); break; case LWS_CALLBACK_CLIENT_CLOSED: - obs_log(LOG_DEBUG, "[Monitoring] Connection closed"); + obs_log(LOG_INFO, "[XboxMonitor] WebSocket connection closed"); on_websocket_disconnected(); break; @@ -785,7 +798,7 @@ static void *monitoring_thread(void *arg) { ctx->context = lws_create_context(&info); if (!ctx->context) { - obs_log(LOG_ERROR, "[Monitoring] Failed to create WebSocket context"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to create WebSocket context"); g_monitoring_context->connected = false; notify_connection_changed("Failed to create WebSocket context"); return (void *)1; @@ -804,12 +817,12 @@ static void *monitoring_thread(void *arg) { ccinfo.retry_and_idle_policy = &retry_policy; ccinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_ALLOW_SELFSIGNED | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK; - obs_log(LOG_DEBUG, "[Monitoring] Connecting to wss://%s:%d%s", RTA_HOST, RTA_PORT, RTA_PATH); + obs_log(LOG_DEBUG, "[XboxMonitor] Connecting to wss://%s:%d%s", RTA_HOST, RTA_PORT, RTA_PATH); ctx->wsi = lws_client_connect_via_info(&ccinfo); if (!ctx->wsi) { - obs_log(LOG_ERROR, "[Monitoring] Failed to connect"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to connect"); g_monitoring_context->connected = false; notify_connection_changed("Failed to connect"); lws_context_destroy(ctx->context); @@ -826,7 +839,7 @@ static void *monitoring_thread(void *arg) { /* Reconnect if the connection was lost */ if (ctx->running && !ctx->wsi && ctx->context) { - obs_log(LOG_INFO, "[Monitoring] Connection lost, retrying in %d ms...", retry_delay_ms); + obs_log(LOG_INFO, "[XboxMonitor] Connection lost, retrying in %d ms...", retry_delay_ms); /* Sleep for retry_delay_ms while keeping lws alive (50ms increments) */ int iterations = retry_delay_ms / LOOP_CHECK_MS; @@ -834,12 +847,14 @@ static void *monitoring_thread(void *arg) { sleep_ms(LOOP_CHECK_MS); } - obs_log(LOG_INFO, "[Monitoring] Reconnecting..."); + obs_log(LOG_INFO, "[XboxMonitor] Reconnecting..."); ctx->wsi = lws_client_connect_via_info(&ccinfo); if (!ctx->wsi) { - obs_log(LOG_ERROR, "[Monitoring] Reconnect attempt failed"); + obs_log(LOG_WARNING, + "[XboxMonitor] Reconnect attempt failed, next retry in %d ms", + retry_delay_ms * 2 > MAX_RETRY_DELAY_MS ? MAX_RETRY_DELAY_MS : retry_delay_ms * 2); /* Double the delay, capped at max */ retry_delay_ms = retry_delay_ms * 2; @@ -847,7 +862,7 @@ static void *monitoring_thread(void *arg) { retry_delay_ms = MAX_RETRY_DELAY_MS; } } else { - obs_log(LOG_INFO, "[Monitoring] Connection reestablished."); + obs_log(LOG_INFO, "[XboxMonitor] Connection reestablished"); /* Reset delay on successful connection attempt */ retry_delay_ms = INITIAL_RETRY_DELAY_MS; } @@ -859,7 +874,7 @@ static void *monitoring_thread(void *arg) { ctx->context = NULL; } - obs_log(LOG_INFO, "[Monitoring] Monitoring thread stopped"); + obs_log(LOG_INFO, "[XboxMonitor] Monitor thread stopped"); return 0; } @@ -873,21 +888,21 @@ bool xbox_monitoring_start() { bool succeeded = false; if (g_monitoring_context) { - obs_log(LOG_INFO, "[Monitoring] Monitoring already started"); + obs_log(LOG_WARNING, "[XboxMonitor] Monitor is already running"); goto done; } xbox_identity_t *identity = xbox_live_get_identity(); if (!identity) { - obs_log(LOG_ERROR, "[Monitoring] No identity available"); + obs_log(LOG_ERROR, "[XboxMonitor] Cannot start monitor: no Xbox identity available"); goto error; } g_monitoring_context = (monitoring_context_t *)bzalloc(sizeof(monitoring_context_t)); if (!g_monitoring_context) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate context"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate context"); goto error; } @@ -904,16 +919,16 @@ bool xbox_monitoring_start() { g_monitoring_context->rx_buffer_used = 0; if (!g_monitoring_context->rx_buffer) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate receive buffer"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate receive buffer"); goto error; } if (pthread_create(&g_monitoring_context->thread, NULL, monitoring_thread, g_monitoring_context) != 0) { - obs_log(LOG_ERROR, "[Monitoring] Failed to create monitoring thread"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to create monitor thread"); goto error; } - obs_log(LOG_INFO, "[Monitoring] Monitoring started"); + obs_log(LOG_INFO, "[XboxMonitor] Monitor started"); succeeded = true; goto done; @@ -934,7 +949,7 @@ void xbox_monitoring_stop(void) { return; } - obs_log(LOG_DEBUG, "[Monitoring] Stopping monitoring"); + obs_log(LOG_INFO, "[XboxMonitor] Stopping monitor"); g_monitoring_context->running = false; @@ -951,7 +966,7 @@ void xbox_monitoring_stop(void) { free_memory((void **)&g_monitoring_context->auth_token); free_memory((void **)&g_monitoring_context); - obs_log(LOG_INFO, "[Monitoring] Monitoring stopped"); + obs_log(LOG_INFO, "[XboxMonitor] Monitor stopped"); } bool xbox_monitoring_is_active(void) { @@ -970,20 +985,27 @@ const gamerscore_t *get_current_gamerscore(void) { return g_current_session.gamerscore; } -const achievement_t *get_current_game_achievements() { +const xbox_achievement_t *get_current_game_achievements() { return g_current_session.achievements; } void xbox_subscribe_game_played(const on_xbox_game_played_t callback) { if (!callback) { + game_played_subscription_t *node = g_game_played_subscriptions; + while (node) { + game_played_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_game_played_subscriptions = NULL; return; } game_played_subscription_t *new_subscription = bzalloc(sizeof(game_played_subscription_t)); if (!new_subscription) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate subscription node"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate subscription node"); return; } @@ -1000,13 +1022,20 @@ void xbox_subscribe_game_played(const on_xbox_game_played_t callback) { void xbox_subscribe_achievements_progressed(on_xbox_achievements_progressed_t callback) { if (!callback) { + achievements_updated_subscription_t *node = g_achievements_updated_subscriptions; + while (node) { + achievements_updated_subscription_t *next = node->next; + bfree(node); + node = next; + } + g_achievements_updated_subscriptions = NULL; return; } achievements_updated_subscription_t *new_subscription = bzalloc(sizeof(achievements_updated_subscription_t)); if (!new_subscription) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate subscription node"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate subscription node"); return; } @@ -1024,7 +1053,7 @@ void xbox_subscribe_connected_changed(const on_xbox_connection_changed_t callbac connection_changed_subscription_t *new_node = bzalloc(sizeof(connection_changed_subscription_t)); if (!new_node) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate subscription node"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate subscription node"); return; } @@ -1046,7 +1075,7 @@ void xbox_subscribe_session_ready(const on_xbox_session_ready_t callback) { session_ready_subscription_t *new_node = bzalloc(sizeof(session_ready_subscription_t)); if (!new_node) { - obs_log(LOG_ERROR, "[Monitoring] Failed to allocate subscription node"); + obs_log(LOG_ERROR, "[XboxMonitor] Failed to allocate subscription node"); return; } @@ -1061,7 +1090,7 @@ void xbox_subscribe_session_ready(const on_xbox_session_ready_t callback) { bool xbox_monitoring_start() { - obs_log(LOG_WARNING, "[Monitoring] WebSockets support not available, monitoring not started"); + obs_log(LOG_WARNING, "[XboxMonitor] Built without libwebsockets support – monitor unavailable"); return false; } @@ -1080,7 +1109,7 @@ const game_t *get_current_game() { return NULL; } -const achievement_t *get_current_game_achievements() { +const xbox_achievement_t *get_current_game_achievements() { return NULL; } diff --git a/src/xbox/xbox_monitor.h b/src/integrations/xbox/xbox_monitor.h similarity index 96% rename from src/xbox/xbox_monitor.h rename to src/integrations/xbox/xbox_monitor.h index 3f8e56f3..94b29afb 100644 --- a/src/xbox/xbox_monitor.h +++ b/src/integrations/xbox/xbox_monitor.h @@ -46,8 +46,8 @@ typedef void (*on_xbox_game_played_t)(const game_t *game); * @param gamerscore Current gamerscore snapshot. * @param achievements_progress Linked list of achievement progress items. */ -typedef void (*on_xbox_achievements_progressed_t)(const gamerscore_t *gamerscore, - const achievement_progress_t *achievements_progress); +typedef void (*on_xbox_achievements_progressed_t)(const gamerscore_t *gamerscore, + const xbox_achievement_progress_t *achievements_progress); /** * @brief Callback invoked when the connection status changes. @@ -99,7 +99,7 @@ const game_t *get_current_game(void); * * @return Pointer to the cached achievements list, or NULL if not available. */ -const achievement_t *get_current_game_achievements(void); +const xbox_achievement_t *get_current_game_achievements(void); /** * @brief Start monitoring the Xbox Live RTA endpoint. diff --git a/src/xbox/xbox_session.c b/src/integrations/xbox/xbox_session.c similarity index 55% rename from src/xbox/xbox_session.c rename to src/integrations/xbox/xbox_session.c index f4d33ff3..f083eff5 100644 --- a/src/xbox/xbox_session.c +++ b/src/integrations/xbox/xbox_session.c @@ -1,11 +1,14 @@ -#include "xbox/xbox_session.h" +#include "integrations/xbox/xbox_session.h" #include #include "common/types.h" #include "io/cache.h" #include "util/bmem.h" -#include "xbox/xbox_client.h" +#include "integrations/xbox/xbox_client.h" +#include "integrations/xbox/contracts/xbox_achievement.h" +#include "integrations/xbox/contracts/xbox_achievement_progress.h" +#include "integrations/xbox/contracts/xbox_unlocked_achievement.h" #include #include @@ -18,26 +21,18 @@ /** * @brief Context passed to the prefetch background thread. - * - * Bundles the deep-copied achievements list (owned by the thread) and the - * optional completion callback. */ typedef struct prefetch_context { /** Deep-copied achievement list. Freed by the thread when done. */ - achievement_t *achievements; + xbox_achievement_t *achievements; /** Optional callback invoked after all icons have been downloaded. */ xbox_session_ready_callback_t on_ready; } prefetch_context_t; /** * @brief Download a single achievement icon to the local file cache. - * - * Uses the same cache-path convention as image_source_download() so that later - * display requests hit the on-disk cache instead of making another HTTP call. - * - * @param achievement */ -static bool download_icon_to_cache(const achievement_t *achievement) { +static bool download_icon_to_cache(const xbox_achievement_t *achievement) { if (!achievement->icon_url || achievement->icon_url[0] == '\0') { return false; @@ -52,52 +47,35 @@ static bool download_icon_to_cache(const achievement_t *achievement) { /** * @brief Background thread entry point: prefetches all achievement icons. - * - * Receives a prefetch_context_t (deep-copied achievement list + optional - * callback), downloads every icon to the local cache, invokes the callback, - * then frees all resources. The thread is created detached, so no join is - * required. - * - * @param arg Pointer to a heap-allocated prefetch_context_t. Ownership is - * transferred to this thread. - * @return NULL (unused). */ static void *prefetch_icons_thread(void *arg) { prefetch_context_t *ctx = arg; - achievement_t *achievements = ctx->achievements; + xbox_achievement_t *achievements = ctx->achievements; int count = 0; - for (const achievement_t *achievement = achievements; achievement != NULL; achievement = achievement->next) { + for (const xbox_achievement_t *achievement = achievements; achievement != NULL; achievement = achievement->next) { if (download_icon_to_cache(achievement)) { sleep_ms(5000); } count++; } - obs_log(LOG_INFO, "[Prefetch] Finished prefetching %d achievement icons", count); + obs_log(LOG_INFO, "[XboxSession] Finished prefetching %d achievement icons", count); if (ctx->on_ready) { ctx->on_ready(); } - free_achievement(&achievements); + xbox_free_achievement(&achievements); free_memory((void **)&ctx); return NULL; } /** * @brief Starts a background thread to prefetch all achievement icons. - * - * Deep-copies the given achievements list and passes ownership to a detached - * pthread that downloads each icon to the local file cache. When finished, - * @p on_ready is invoked (if non-NULL) from the background thread. - * - * @param achievements Head of the achievements list to prefetch icons for. - * The caller retains ownership; the function makes its own copy. - * @param on_ready Optional callback invoked when all icons have been downloaded. */ -static void prefetch_achievement_icons(const achievement_t *achievements, xbox_session_ready_callback_t on_ready) { +static void prefetch_achievement_icons(const xbox_achievement_t *achievements, xbox_session_ready_callback_t on_ready) { if (!achievements) { if (on_ready) { @@ -106,10 +84,10 @@ static void prefetch_achievement_icons(const achievement_t *achievements, xbox_s return; } - achievement_t *copy = copy_achievement(achievements); + xbox_achievement_t *copy = xbox_copy_achievement(achievements); if (!copy) { - obs_log(LOG_WARNING, "[Prefetch] Failed to copy achievements for icon prefetch"); + obs_log(LOG_WARNING, "[XboxSession] Failed to copy achievements for icon prefetch"); if (on_ready) { on_ready(); } @@ -123,10 +101,10 @@ static void prefetch_achievement_icons(const achievement_t *achievements, xbox_s pthread_t thread; if (pthread_create(&thread, NULL, prefetch_icons_thread, ctx) == 0) { pthread_detach(thread); - obs_log(LOG_INFO, "[Prefetch] Started background icon prefetch thread"); + obs_log(LOG_INFO, "[XboxSession] Started background icon prefetch thread"); } else { - obs_log(LOG_ERROR, "[Prefetch] Failed to create icon prefetch thread"); - free_achievement(©); + obs_log(LOG_ERROR, "[XboxSession] Failed to create icon prefetch thread"); + xbox_free_achievement(©); free_memory((void **)&ctx); if (on_ready) { on_ready(); @@ -139,27 +117,17 @@ static void prefetch_achievement_icons(const achievement_t *achievements, xbox_s // -------------------------------------------------------------------------------------------------------------------- /** - * @brief Finds an achievement definition by id. - * - * Performs a case-insensitive search of the @p achievements linked list for an - * entry whose @c id matches @c progress->id. - * - * @param progress Progress item containing the achievement id to look up. - * @param achievements Head of the achievements linked list. - * - * @return Pointer to the matching achievement node within @p achievements, or - * NULL if not found. + * @brief Finds an Xbox achievement definition by id. */ -static achievement_t *find_achievement_by_id(const achievement_progress_t *progress, achievement_t *achievements) { +static xbox_achievement_t *find_achievement_by_id(const xbox_achievement_progress_t *progress, + xbox_achievement_t *achievements) { - achievement_t *current = achievements; + xbox_achievement_t *current = achievements; while (current) { - if (strcasecmp(current->id, progress->id) == 0) { return current; } - current = current->next; } @@ -188,14 +156,13 @@ bool xbox_session_is_game_played(xbox_session_t *session, const game_t *game) { void xbox_session_change_game(xbox_session_t *session, game_t *game, xbox_session_ready_callback_t on_ready) { if (!session) { - obs_log(LOG_ERROR, "Failed to change game: session is NULL"); + obs_log(LOG_ERROR, "[XboxSession] Failed to change game: session is NULL"); return; } - free_achievement(&session->achievements); + xbox_free_achievement(&session->achievements); free_game(&session->game); - /* Let's get the achievements of the game */ if (!game) { if (on_ready) { on_ready(); @@ -206,26 +173,22 @@ void xbox_session_change_game(xbox_session_t *session, game_t *game, xbox_sessio session->game = copy_game(game); session->achievements = xbox_get_game_achievements(game); - /* Sort the achievements from the most recent unlocked to the locked ones */ - sort_achievements(&session->achievements); + xbox_sort_achievements(&session->achievements); - /* Prefetch all achievement icons in the background; on_ready fires when done */ prefetch_achievement_icons(session->achievements, on_ready); } -void xbox_session_unlock_achievement(xbox_session_t *session, const achievement_progress_t *progress) { +void xbox_session_unlock_achievement(xbox_session_t *session, const xbox_achievement_progress_t *progress) { if (!session || !progress) { return; } - /* TODO Let's make sure the progress is achieved */ - - achievement_t *achievement = find_achievement_by_id(progress, session->achievements); + xbox_achievement_t *achievement = find_achievement_by_id(progress, session->achievements); if (!achievement) { obs_log(LOG_ERROR, - "Failed to unlock achievement %s: not found in the game's achievements", + "[XboxSession] Failed to unlock achievement %s: not found in the game's achievements", progress->id ? progress->id : "(null)"); return; } @@ -235,22 +198,23 @@ void xbox_session_unlock_achievement(xbox_session_t *session, const achievement_ achievement->progress_state = bstrdup(progress->progress_state); achievement->unlocked_timestamp = progress->unlocked_timestamp; - /* Sort the achievements from the most recent unlocked to the locked ones */ - sort_achievements(&session->achievements); + xbox_sort_achievements(&session->achievements); - const reward_t *reward = achievement->rewards; + const xbox_reward_t *reward = achievement->rewards; if (!reward) { - obs_log(LOG_ERROR, "Failed to unlock achievement %s: no reward found", progress->id ? progress->id : "(null)"); + obs_log(LOG_ERROR, + "[XboxSession] Failed to unlock achievement %s: no reward found", + progress->id ? progress->id : "(null)"); return; } - obs_log(LOG_DEBUG, "Found reward %s", reward->value); + obs_log(LOG_DEBUG, "[XboxSession] Found reward %s", reward->value); gamerscore_t *gamerscore = session->gamerscore; - unlocked_achievement_t *unlocked_achievement = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement->id = bstrdup(progress->id); + xbox_unlocked_achievement_t *unlocked_achievement = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement->id = bstrdup(progress->id); long parsed_value = 0; char *endptr = NULL; @@ -262,7 +226,7 @@ void xbox_session_unlock_achievement(xbox_session_t *session, const achievement_ if (errno != 0 || endptr == reward->value || (endptr && *endptr != '\0')) { obs_log(LOG_WARNING, - "Unable to parse gamerscore value '%s' for achievement %s; defaulting to 0", + "[XboxSession] Unable to parse gamerscore value '%s' for achievement %s; defaulting to 0", reward->value ? reward->value : "(null)", progress->id ? progress->id : "(null)"); parsed_value = 0; @@ -270,23 +234,20 @@ void xbox_session_unlock_achievement(xbox_session_t *session, const achievement_ unlocked_achievement->value = (int)parsed_value; - unlocked_achievement_t *unlocked_achievements = gamerscore->unlocked_achievements; + xbox_unlocked_achievement_t *unlocked_achievements = gamerscore->unlocked_achievements; - /* Appends the unlocked achievement to the list */ if (!unlocked_achievements) { gamerscore->unlocked_achievements = unlocked_achievement; } else { - unlocked_achievement_t *last_unlocked_achievement = unlocked_achievements; + xbox_unlocked_achievement_t *last_unlocked_achievement = unlocked_achievements; while (last_unlocked_achievement->next) { last_unlocked_achievement = last_unlocked_achievement->next; } last_unlocked_achievement->next = unlocked_achievement; } - /* Sort achievements from the most recent unlocked achievement first and then locked achievements */ - obs_log(LOG_INFO, - "New achievement unlocked: %s (%d G)! Gamerscore is now %d", + "[XboxSession] Achievement unlocked: %s (%d G) β€” gamerscore now %d", achievement->name, unlocked_achievement->value, xbox_session_compute_gamerscore(session)); @@ -298,7 +259,7 @@ void xbox_session_clear(xbox_session_t *session) { return; } - free_achievement(&session->achievements); + xbox_free_achievement(&session->achievements); free_game(&session->game); free_gamerscore(&session->gamerscore); } diff --git a/src/xbox/xbox_session.h b/src/integrations/xbox/xbox_session.h similarity index 98% rename from src/xbox/xbox_session.h rename to src/integrations/xbox/xbox_session.h index bb55f026..d507af85 100644 --- a/src/xbox/xbox_session.h +++ b/src/integrations/xbox/xbox_session.h @@ -70,7 +70,7 @@ void xbox_session_change_game(xbox_session_t *session, game_t *game, xbox_sessio * @param session Session to update. * @param progress Progress information for the achievement being unlocked. */ -void xbox_session_unlock_achievement(xbox_session_t *session, const achievement_progress_t *progress); +void xbox_session_unlock_achievement(xbox_session_t *session, const xbox_achievement_progress_t *progress); /** * @brief Clears all session state. diff --git a/src/io/cache.c b/src/io/cache.c index c24e316c..f993a648 100644 --- a/src/io/cache.c +++ b/src/io/cache.c @@ -5,60 +5,88 @@ #include #include #include +#include -#ifdef _WIN32 -#include -#define CACHE_MAX_PATH MAX_PATH -#else #include -#define CACHE_MAX_PATH PATH_MAX + +/* PATH_MAX is a POSIX constant absent from Windows / MSVC's limits.h. + * MAX_PATH (260) is the traditional Win32 limit; use it as a fallback. */ +#ifndef PATH_MAX +#ifdef MAX_PATH +#define PATH_MAX MAX_PATH +#else +#define PATH_MAX 4096 +#endif #endif +#define CACHE_MAX_PATH PATH_MAX + #include #include #include "common/memory.h" -static const char *get_temp_dir(char *buf, size_t buf_size) { - // TMPDIR β€” macOS and most Linux distros - const char *dir = getenv("TMPDIR"); - if (dir && dir[0] != '\0') - return dir; - - // TEMP / TMP β€” Windows (and some Linux environments) - dir = getenv("TEMP"); - if (dir && dir[0] != '\0') - return dir; - - dir = getenv("TMP"); - if (dir && dir[0] != '\0') - return dir; - -#ifdef _WIN32 - // Last resort on Windows: ask the OS directly - DWORD len = GetTempPathA((DWORD)buf_size, buf); - if (len > 0 && len < buf_size) - return buf; -#endif - UNUSED_PARAMETER(buf); - UNUSED_PARAMETER(buf_size); - // Last resort on POSIX - return "/tmp/"; +#define CACHE_DIRECTORY "cache" + +static uint32_t cache_hash_source(const char *source) { + /* FNV-1a 32-bit: small, stable, and sufficient for cache keying. */ + const unsigned char *p = (const unsigned char *)(source ? source : ""); + uint32_t h = 2166136261u; + + while (*p) { + h ^= (uint32_t)*p++; + h *= 16777619u; + } + + return h; +} + +static bool get_cache_dir(char *buf, size_t buf_size) { + + char *cache_dir = obs_module_config_path(CACHE_DIRECTORY); + + if (!cache_dir) { + if (buf && buf_size > 0) { + buf[0] = '\0'; + } + + return false; + } + + os_mkdirs(cache_dir); + snprintf(buf, buf_size, "%s", cache_dir); + bfree(cache_dir); + return true; } -void cache_build_path(const char *type, const char *id, char *out_path, size_t path_size) { +void cache_build_path(const char *type, const char *id, const char *source, char *out_path, size_t path_size) { - char tmpbuf[CACHE_MAX_PATH] = {0}; - const char *tmpdir = get_temp_dir(tmpbuf, sizeof(tmpbuf)); + char cache_dir[CACHE_MAX_PATH] = {0}; + uint32_t source_hash = cache_hash_source(source); - // Ensure the temp dir ends with a separator - size_t dirlen = strlen(tmpdir); - char sep = (dirlen > 0 && (tmpdir[dirlen - 1] == '/' || tmpdir[dirlen - 1] == '\\')) ? '\0' : '/'; + if (!get_cache_dir(cache_dir, sizeof(cache_dir))) { + if (out_path && path_size > 0) { + out_path[0] = '\0'; + } + + return; + } + + // Ensure the cache dir ends with a separator + size_t dirlen = strlen(cache_dir); + char sep = (dirlen > 0 && (cache_dir[dirlen - 1] == '/' || cache_dir[dirlen - 1] == '\\')) ? '\0' : '/'; if (sep) - snprintf(out_path, path_size, "%s%cobs_achievement_tracker_%s_%s.png", tmpdir, sep, type, id); + snprintf(out_path, + path_size, + "%s%cobs_achievement_tracker_%s_%s_%08x.png", + cache_dir, + sep, + type, + id, + source_hash); else - snprintf(out_path, path_size, "%sobs_achievement_tracker_%s_%s.png", tmpdir, type, id); + snprintf(out_path, path_size, "%sobs_achievement_tracker_%s_%s_%08x.png", cache_dir, type, id, source_hash); } bool cache_download(const char *url, const char *type, const char *id, char *out_path, size_t path_size) { @@ -68,7 +96,12 @@ bool cache_download(const char *url, const char *type, const char *id, char *out } char path_buf[1024]; - cache_build_path(type, id, path_buf, sizeof(path_buf)); + cache_build_path(type, id, url, path_buf, sizeof(path_buf)); + + if (path_buf[0] == '\0') { + obs_log(LOG_ERROR, "[Cache] Failed to resolve the OBS module cache directory"); + return false; + } /* Copy the resolved path to the caller's buffer when provided */ if (out_path) { @@ -78,18 +111,41 @@ bool cache_download(const char *url, const char *type, const char *id, char *out /* Already cached β€” nothing to do */ struct stat st; if (stat(path_buf, &st) == 0) { - obs_log(LOG_DEBUG, "[Cache] Hit: %s", path_buf); - return false; + if (st.st_size == 0) { + obs_log(LOG_WARNING, "[Cache] Discarding zero-byte cached file '%s'", path_buf); + + if (remove(path_buf) != 0) { + obs_log(LOG_WARNING, "[Cache] Failed to delete zero-byte cached file '%s'", path_buf); + return false; + } + } else { + obs_log(LOG_DEBUG, "[Cache] Hit: %s", path_buf); + return true; + } } /* Download into memory */ uint8_t *data = NULL; size_t size = 0; - obs_log(LOG_INFO, "[Cache] Downloading '%s'", url); + /* Normalize the URL so that any unencoded characters in the path or query + * (e.g. spaces, Unicode) are properly percent-encoded. */ + char *encoded_url = http_encode_url(url); + const char *download_url = encoded_url ? encoded_url : url; + + obs_log(LOG_INFO, "[Cache] Downloading '%s'", download_url); + + if (!http_download(download_url, &data, &size)) { + obs_log(LOG_WARNING, "[Cache] Failed to download '%s'", download_url); + bfree(encoded_url); + return false; + } - if (!http_download(url, &data, &size)) { - obs_log(LOG_WARNING, "[Cache] Failed to download '%s'", url); + bfree(encoded_url); + + if (size == 0) { + obs_log(LOG_WARNING, "[Cache] Downloaded zero bytes from '%s'", download_url); + free_memory((void **)&data); return false; } diff --git a/src/io/cache.h b/src/io/cache.h index 625370ab..7a990365 100644 --- a/src/io/cache.h +++ b/src/io/cache.h @@ -17,7 +17,7 @@ extern "C" { * that the path convention is defined in exactly one place. * * Cache path format: - * `/obs_achievement_tracker__.png` + * `/cache/obs_achievement_tracker___.png` * * Thread safety: * All functions are safe to call from any thread. Two concurrent downloads @@ -26,23 +26,24 @@ extern "C" { */ /** - * @brief Build the canonical cache file path for a given type and id. + * @brief Build the canonical cache file path for a given type, id, and source URL. * * Writes the path into @p out_path using the naming convention: - * `/obs_achievement_tracker__.png` + * `/cache/obs_achievement_tracker___.png` * * @param type Category suffix (e.g. "achievement_icon", "gamerpic", "game_cover"). * @param id Unique identifier for this resource. + * @param source Source URL used to derive a stable hash for cache busting. * @param out_path Destination buffer for the resulting path. * @param path_size Size of @p out_path in bytes. */ -void cache_build_path(const char *type, const char *id, char *out_path, size_t path_size); +void cache_build_path(const char *type, const char *id, const char *source, char *out_path, size_t path_size); /** * @brief Download a remote resource to the local file cache (if not already cached). * - * Builds the cache path from @p type and @p id, checks whether the file already - * exists on disk, and downloads it from @p url only when necessary. + * Builds the cache path from @p type, @p id, and @p url, checks whether the file + * already exists on disk, and downloads it from @p url only when necessary. * * On success the resulting file path is written into @p out_path (if non-NULL) * so that the caller can use it immediately (e.g. for texture creation). @@ -55,7 +56,8 @@ void cache_build_path(const char *type, const char *id, char *out_path, size_t p * @param path_size Size of @p out_path in bytes (ignored when @p out_path is NULL). * * @return true if the file is present in the cache after this call (either - * already existed or was successfully downloaded); false on failure. + * already existed or was successfully downloaded); false on failure, + * including when the OBS module cache directory cannot be resolved. */ bool cache_download(const char *url, const char *type, const char *id, char *out_path, size_t path_size); diff --git a/src/main.c b/src/main.c index b572da93..4a8fde71 100644 --- a/src/main.c +++ b/src/main.c @@ -3,18 +3,18 @@ #include "sources/common/achievement_cycle.h" #include "ui/xbox_account_config.h" -#include "sources/xbox/gamerpic.h" -#include "sources/xbox/game_cover.h" -#include "sources/xbox/gamerscore.h" -#include "sources/xbox/gamertag.h" +#include "sources/gamerpic.h" +#include "sources/game_cover.h" +#include "sources/gamerscore.h" +#include "sources/gamertag.h" #include "io/state.h" -#include "sources/xbox/achievement_name.h" -#include "sources/xbox/achievement_description.h" -#include "sources/xbox/achievement_icon.h" -#include "sources/xbox/achievements_count.h" +#include "sources/achievement_name.h" +#include "sources/achievement_description.h" +#include "sources/achievement_icon.h" +#include "sources/achievements_count.h" #include "drawing/image.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" OBS_DECLARE_MODULE() OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US") @@ -24,10 +24,10 @@ bool obs_module_load(void) { io_load(); xbox_account_config_register(); - xbox_monitoring_start(); + monitoring_start(); xbox_gamerpic_source_register(); - xbox_game_cover_source_register(); + game_cover_source_register(); xbox_gamerscore_source_register(); xbox_gamertag_source_register(); @@ -55,11 +55,12 @@ void obs_module_unload(void) { xbox_achievement_description_source_cleanup(); xbox_achievement_icon_source_cleanup(); xbox_achievements_count_source_cleanup(); - xbox_game_cover_source_cleanup(); + game_cover_source_cleanup(); xbox_gamerpic_source_cleanup(); xbox_gamerscore_source_cleanup(); xbox_gamertag_source_cleanup(); + monitoring_stop(); io_cleanup(); obs_log(LOG_INFO, "Plugin unloaded"); diff --git a/src/net/http/http.c b/src/net/http/http.c index e90aa563..4d943092 100644 --- a/src/net/http/http.c +++ b/src/net/http/http.c @@ -394,6 +394,141 @@ char *http_urlencode(const char *in) { return out; } +static bool http_is_hex_digit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +static bool http_is_unreserved(unsigned char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || + c == '_' || c == '~'; +} + +static void http_append_pct(char *out, size_t *pos, unsigned char c) { + static const char hex[] = "0123456789ABCDEF"; + out[(*pos)++] = '%'; + out[(*pos)++] = hex[(c >> 4) & 0x0F]; + out[(*pos)++] = hex[c & 0x0F]; +} + +char *http_encode_url(const char *url) { + + if (!url) + return NULL; + + const char *scheme_end = strstr(url, "://"); + if (!scheme_end) + return bstrdup(url); + + const char *host_start = scheme_end + 3; + const char *path_start = strchr(host_start, '/'); + const char *query_start = strchr(host_start, '?'); + const char *fragment_start = strchr(host_start, '#'); + const char *first_delim = NULL; + + if (path_start) + first_delim = path_start; + if (query_start && (!first_delim || query_start < first_delim)) + first_delim = query_start; + if (fragment_start && (!first_delim || fragment_start < first_delim)) + first_delim = fragment_start; + + if (!first_delim) + return bstrdup(url); + + size_t prefix_len = (size_t)(first_delim - url); + + CURL *curl = curl_easy_init(); + if (!curl) + return NULL; + + /* Worst case: every remaining byte is percent-encoded. */ + size_t suffix_len = strlen(first_delim); + size_t buf_size = prefix_len + suffix_len * 3 + 1; + char *out = bzalloc(buf_size); + if (!out) { + curl_easy_cleanup(curl); + return NULL; + } + + /* Copy scheme + authority verbatim. */ + memcpy(out, url, prefix_len); + size_t pos = prefix_len; + + const char *p = first_delim; + + /* Path: preserve '/', preserve existing %XX escapes, encode the rest of + * unsafe bytes. Stop at '?' or '#'. */ + while (*p && *p != '?' && *p != '#') { + unsigned char c = (unsigned char)*p; + + if (c == '/') { + out[pos++] = '/'; + p++; + } else if (c == '%' && http_is_hex_digit(p[1]) && http_is_hex_digit(p[2])) { + out[pos++] = p[0]; + out[pos++] = p[1]; + out[pos++] = p[2]; + p += 3; + } else if (http_is_unreserved(c)) { + out[pos++] = (char)c; + p++; + } else { + http_append_pct(out, &pos, c); + p++; + } + } + + /* Query: preserve '?', '&', '=', and existing %XX escapes. Encode only + * unsafe bytes like spaces. Stop at '#'. */ + if (*p == '?') { + out[pos++] = *p++; + while (*p && *p != '#') { + unsigned char c = (unsigned char)*p; + + if (c == '%' && http_is_hex_digit(p[1]) && http_is_hex_digit(p[2])) { + out[pos++] = p[0]; + out[pos++] = p[1]; + out[pos++] = p[2]; + p += 3; + } else if (http_is_unreserved(c) || c == '&' || c == '=' || c == ';' || c == ':' || c == ',' || c == '+' || + c == '/' || c == '@' || c == '?' || c == '-') { + out[pos++] = (char)c; + p++; + } else { + http_append_pct(out, &pos, c); + p++; + } + } + } + + /* Fragment: preserve '#' and existing %XX escapes; encode only unsafe + * bytes. */ + if (*p == '#') { + out[pos++] = *p++; + while (*p) { + unsigned char c = (unsigned char)*p; + + if (c == '%' && http_is_hex_digit(p[1]) && http_is_hex_digit(p[2])) { + out[pos++] = p[0]; + out[pos++] = p[1]; + out[pos++] = p[2]; + p += 3; + } else if (http_is_unreserved(c) || c == '/' || c == '?' || c == '&' || c == '=' || c == '-' || c == '.') { + out[pos++] = (char)c; + p++; + } else { + http_append_pct(out, &pos, c); + p++; + } + } + } + + out[pos] = '\0'; + curl_easy_cleanup(curl); + + return out; +} + bool http_download(const char *url, uint8_t **out_data, size_t *out_size) { if (!url || !out_data || !out_size) @@ -420,6 +555,9 @@ bool http_download(const char *url, uint8_t **out_data, size_t *out_size) { CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_cleanup(curl); if (res != CURLE_OK) { @@ -428,6 +566,12 @@ bool http_download(const char *url, uint8_t **out_data, size_t *out_size) { return false; } + if (http_code < 200 || http_code >= 300) { + obs_log(LOG_ERROR, "Download failed: server returned HTTP %ld for '%s'", http_code, url); + bfree(buf.data); + return false; + } + *out_data = buf.data; *out_size = buf.size; diff --git a/src/net/http/http.h b/src/net/http/http.h index ee9f5a55..e5b71d21 100644 --- a/src/net/http/http.h +++ b/src/net/http/http.h @@ -89,6 +89,21 @@ bool http_download(const char *url, uint8_t **out_data, size_t *out_size); */ char *http_urlencode(const char *in); +/** + * @brief Normalize and percent-encode a full URL. + * + * Preserves the URL structure (scheme, authority, path separators, query + * delimiters, fragment delimiters, and existing %XX escapes) while + * percent-encoding only unsafe bytes inside the path/query/fragment + * components. This is safe to call on a complete URL β€” unlike + * @ref http_urlencode which would escape structural delimiters like '/', '?', + * and ':'. + * + * @param url The raw URL to normalize. + * @return Newly allocated encoded URL (caller must bfree()), or NULL on error. + */ +char *http_encode_url(const char *url); + #ifdef __cplusplus } #endif diff --git a/src/sources/xbox/achievement_description.c b/src/sources/achievement_description.c similarity index 97% rename from src/sources/xbox/achievement_description.c rename to src/sources/achievement_description.c index 8f36bb36..684f5867 100644 --- a/src/sources/xbox/achievement_description.c +++ b/src/sources/achievement_description.c @@ -1,4 +1,4 @@ -#include "sources/xbox/achievement_description.h" +#include "sources/achievement_description.h" #include "sources/common/achievement_cycle.h" #include "sources/common/text_source.h" @@ -265,16 +265,16 @@ static obs_properties_t *source_get_properties(void *data) { * @brief OBS callback returning the display name for this source type. * * @param unused Unused parameter. - * @return Static string "Xbox Achievement (Description)" displayed in OBS source list. + * @return Static string "Achievement (Description)" displayed in OBS source list. */ static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Achievement (Description)"; + return "Achievement (Description)"; } /** - * @brief obs_source_info describing the Xbox Achievement Description source. + * @brief obs_source_info describing the Achievement Description source. * * Defines the OBS source type for displaying achievement descriptions. This structure * specifies the source ID, type, capabilities, and callback functions for all diff --git a/src/sources/xbox/achievement_description.h b/src/sources/achievement_description.h similarity index 80% rename from src/sources/xbox/achievement_description.h rename to src/sources/achievement_description.h index 60a15bab..b5da4081 100644 --- a/src/sources/xbox/achievement_description.h +++ b/src/sources/achievement_description.h @@ -5,11 +5,11 @@ extern "C" { #endif /** - * @brief Register the Xbox Achievement Description source with OBS. + * @brief Register the Achievement Description source with OBS. * * This function registers an OBS source that displays the description of the most - * recently unlocked Xbox achievement. The source automatically updates when new - * achievements are unlocked by subscribing to Xbox monitor events. + * recently unlocked achievement. The source automatically updates when new + * achievements are unlocked by subscribing to monitoring service events. * * The source provides the following features: * - Displays the achievement description text @@ -20,7 +20,7 @@ extern "C" { * This function should be called once during plugin initialization (typically in * obs_module_load()) to make the source available in OBS. * - * @note This function allocates resources and subscribes to Xbox monitor callbacks. + * @note This function allocates resources and subscribes to monitoring callbacks. * The source configuration is persisted to disk via the state management system. * * @see xbox_achievement_name_source_register() for registering the achievement name source diff --git a/src/sources/xbox/achievement_icon.c b/src/sources/achievement_icon.c similarity index 92% rename from src/sources/xbox/achievement_icon.c rename to src/sources/achievement_icon.c index 2cb03540..1dffc523 100644 --- a/src/sources/xbox/achievement_icon.c +++ b/src/sources/achievement_icon.c @@ -1,11 +1,10 @@ -#include "sources/xbox/achievement_icon.h" +#include "sources/achievement_icon.h" #include #include #include #include "common/achievement.h" -#include "oauth/xbox-live.h" #include "sources/common/achievement_cycle.h" #include "sources/common/image_source.h" @@ -140,11 +139,7 @@ static void update_achievement_icon(const achievement_t *achievement) { // OBS video/render thread with HTTP I/O. g_transition.pending_is_unlocked = is_new_unlocked_achievement; g_pending_has_state_changed = has_state_changed; - snprintf(g_next_achievement_icon->id, - sizeof(g_next_achievement_icon->id), - "%s_%s", - achievement->service_config_id, - achievement->id); + snprintf(g_next_achievement_icon->id, sizeof(g_next_achievement_icon->id), "%s", achievement->id); snprintf(g_next_achievement_icon->url, sizeof(g_next_achievement_icon->url), "%s", achievement->icon_url); pthread_t thread; @@ -202,13 +197,13 @@ static uint32_t source_get_height(void *data) { * @brief OBS callback returning the display name for this source type. * * @param unused Unused parameter. - * @return Static string "Xbox Achievement (Icon)" displayed in OBS source list. + * @return Static string "Achievement (Icon)" displayed in OBS source list. */ static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Achievement (Icon)"; + return "Achievement (Icon)"; } /** @@ -387,7 +382,7 @@ static void on_source_video_tick(void *data, float seconds) { /** * @brief OBS callback constructing the properties UI for the achievement icon source. * - * Shows connection status to Xbox Live. Currently, provides no editable settings. + * Currently, provides no editable settings. * * @param data Source instance data (unused). * @return Newly created obs_properties_t structure containing the UI controls. @@ -396,30 +391,13 @@ static obs_properties_t *source_get_properties(void *data) { UNUSED_PARAMETER(data); - /* Gets or refreshes the token */ - xbox_identity_t *xbox_identity = xbox_live_get_identity(); - - /* Lists all the UI components of the properties page */ obs_properties_t *p = obs_properties_create(); - if (xbox_identity != NULL) { - char status[4096]; - snprintf(status, 4096, "Connected to your xbox account as %s", xbox_identity->gamertag); - obs_properties_add_text(p, "connected_status_info", status, OBS_TEXT_INFO); - } else { - obs_properties_add_text(p, - "disconnected_status_info", - "You are not connected to your xbox account", - OBS_TEXT_INFO); - } - - free_identity(&xbox_identity); - return p; } /** - * @brief obs_source_info describing the Xbox Achievement Icon source. + * @brief obs_source_info describing the Achievement Icon source. * * Defines the OBS source type for displaying achievement icons. This structure * specifies the source ID, type, capabilities, and callback functions for all diff --git a/src/sources/xbox/achievement_icon.h b/src/sources/achievement_icon.h similarity index 79% rename from src/sources/xbox/achievement_icon.h rename to src/sources/achievement_icon.h index 8f176264..b7d35940 100644 --- a/src/sources/xbox/achievement_icon.h +++ b/src/sources/achievement_icon.h @@ -5,11 +5,11 @@ extern "C" { #endif /** - * @brief Register the Xbox Achievement Icon source with OBS. + * @brief Register the Achievement Icon source with OBS. * * This function registers an OBS source that displays the icon image of the most - * recently unlocked Xbox achievement. The source automatically updates when new - * achievements are unlocked by subscribing to Xbox monitor events. + * recently unlocked achievement. The source automatically updates when new + * achievements are unlocked by subscribing to monitoring service events. * * The source provides the following features: * - Displays the achievement icon as a texture @@ -20,7 +20,7 @@ extern "C" { * This function should be called once during plugin initialization (typically in * obs_module_load()) to make the source available in OBS. * - * @note This function allocates resources and subscribes to Xbox monitor callbacks. + * @note This function allocates resources and subscribes to monitoring callbacks. * @note The icon image is downloaded to a temporary file and loaded as a GPU texture. * * @see xbox_achievement_name_source_register() for registering the achievement name source diff --git a/src/sources/xbox/achievement_name.c b/src/sources/achievement_name.c similarity index 96% rename from src/sources/xbox/achievement_name.c rename to src/sources/achievement_name.c index 2b8ec9a0..8d25d4c9 100644 --- a/src/sources/xbox/achievement_name.c +++ b/src/sources/achievement_name.c @@ -1,6 +1,6 @@ /** * @file achievement_name.c - * @brief OBS source for displaying Xbox achievement names. + * @brief OBS source for displaying achievement names. * * This module implements an OBS video source that renders the current achievement's * name and gamerscore as text (e.g., "50G - Master Explorer"). The source automatically @@ -21,7 +21,7 @@ * @see text_source.h for the common text rendering infrastructure */ -#include "sources/xbox/achievement_name.h" +#include "sources/achievement_name.h" #include "sources/common/achievement_cycle.h" #include "sources/common/text_source.h" @@ -139,12 +139,8 @@ static void update_achievement_name(const achievement_t *achievement) { g_must_reload = true; } - if (achievement->rewards && achievement->rewards->value) { - snprintf(g_achievement_name, - sizeof(g_achievement_name), - "%sG - %s", - achievement->rewards->value, - achievement->name); + if (achievement->value > 0) { + snprintf(g_achievement_name, sizeof(g_achievement_name), "%d - %s", achievement->value, achievement->name); } else { snprintf(g_achievement_name, sizeof(g_achievement_name), "%s", achievement->name); } @@ -351,16 +347,16 @@ static obs_properties_t *source_get_properties(void *data) { * @brief OBS callback returning the display name for this source type. * * @param unused Unused parameter. - * @return Static string "Xbox Achievement (Name)" displayed in OBS source list. + * @return Static string "Achievement (Name)" displayed in OBS source list. */ static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Achievement (Name)"; + return "Achievement (Name)"; } /** - * @brief obs_source_info describing the Xbox Achievement Name source. + * @brief obs_source_info describing the Achievement Name source. * * Defines the OBS source type for displaying achievement names. This structure * specifies the source ID, type, capabilities, and callback functions for all diff --git a/src/sources/xbox/achievement_name.h b/src/sources/achievement_name.h similarity index 79% rename from src/sources/xbox/achievement_name.h rename to src/sources/achievement_name.h index eb43e10f..01a91f52 100644 --- a/src/sources/xbox/achievement_name.h +++ b/src/sources/achievement_name.h @@ -5,11 +5,11 @@ extern "C" { #endif /** - * @brief Register the Xbox Achievement Name source with OBS. + * @brief Register the Achievement Name source with OBS. * * This function registers an OBS source that displays the name and gamerscore value - * of the most recently unlocked Xbox achievement. The source automatically updates - * when new achievements are unlocked by subscribing to Xbox monitor events. + * of the most recently unlocked achievement. The source automatically updates + * when new achievements are unlocked by subscribing to monitoring service events. * * The source provides the following features: * - Displays achievement name and gamerscore value (e.g., "50G - Master Explorer") @@ -20,7 +20,7 @@ extern "C" { * This function should be called once during plugin initialization (typically in * obs_module_load()) to make the source available in OBS. * - * @note This function allocates resources and subscribes to Xbox monitor callbacks. + * @note This function allocates resources and subscribes to monitoring callbacks. * The source configuration is persisted to disk via the state management system. * * @see xbox_gamerscore_source_register() for registering the gamerscore display source diff --git a/src/sources/xbox/achievements_count.c b/src/sources/achievements_count.c similarity index 84% rename from src/sources/xbox/achievements_count.c rename to src/sources/achievements_count.c index da9e61f5..daceeca8 100644 --- a/src/sources/xbox/achievements_count.c +++ b/src/sources/achievements_count.c @@ -1,15 +1,15 @@ -#include "sources/xbox/achievements_count.h" +#include "sources/achievements_count.h" /** * @file achievements_total_count.c * @brief OBS source that renders the total number of achievements for the current game. * * This source displays the total count of achievements available for the currently - * played Xbox game. The count is updated when the game changes. + * played game. The count is updated when the game changes or achievements progress. * * Data flow: - * - The Xbox monitor notifies this module when the connection state changes or - * when the game changes. + * - The monitoring service notifies this module when the connection state changes, + * when the game changes, or when achievements are updated. * - The module counts total achievements and stores the result in a global. * - During rendering, the count is formatted to text and rendered. * @@ -27,8 +27,7 @@ #include "common/achievement.h" #include "io/state.h" -#include "oauth/xbox-live.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" #define NO_FLIP 0 @@ -47,19 +46,26 @@ static achievements_count_configuration_t *g_configuration; * @brief Recompute and store the total achievements count. */ static void update_count(void) { - const achievement_t *achievements = get_current_game_achievements(); + const achievement_t *achievements = monitoring_get_current_game_achievements(); int unlocked = count_unlocked_achievements(achievements); int total = count_achievements(achievements); - snprintf(g_total_count, sizeof(g_total_count), "%d / %d", unlocked, total); + if (unlocked != total) { + snprintf(g_total_count, sizeof(g_total_count), "%d / %d", unlocked, total); + } else if (total > 0) { + snprintf(g_total_count, sizeof(g_total_count), "Mastered"); + } else { + g_total_count[0] = '\0'; + } + g_must_reload = true; obs_log(LOG_INFO, "[Achievements Counter] %d achievements unlocked out of %d", unlocked, total); } /** - * @brief Xbox monitor callback invoked when a new game is played. + * @brief Monitoring service callback invoked when a new game is played. * * @param game Current game information. */ @@ -74,15 +80,9 @@ static void on_game_played(const game_t *game) { } /** - * @brief Xbox monitor callback invoked when achievements progress. - * - * @param gamerscore Updated gamerscore snapshot (unused). - * @param progress Achievement progress details (unused). + * @brief Monitoring service callback invoked when achievements change. */ -static void on_achievements_progressed(const gamerscore_t *gamerscore, const achievement_progress_t *progress) { - UNUSED_PARAMETER(gamerscore); - UNUSED_PARAMETER(progress); - +static void on_achievements_changed(void) { update_count(); } @@ -189,11 +189,11 @@ static obs_properties_t *source_get_properties(void *data) { static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Achievements Count"; + return "Achievements Count"; } /** - * @brief obs_source_info describing the Xbox Achievements Total Count source. + * @brief obs_source_info describing the Achievements Total Count source. */ static struct obs_source_info xbox_achievements_count_source = { .id = "xbox_achievements_count_source", @@ -228,8 +228,8 @@ void xbox_achievements_count_source_register(void) { obs_register_source(xbox_source_get()); - xbox_subscribe_achievements_progressed(&on_achievements_progressed); - xbox_subscribe_game_played(&on_game_played); + monitoring_subscribe_achievements_changed(&on_achievements_changed); + monitoring_subscribe_game_played(&on_game_played); } void xbox_achievements_count_source_cleanup(void) { diff --git a/src/sources/xbox/achievements_count.h b/src/sources/achievements_count.h similarity index 85% rename from src/sources/xbox/achievements_count.h rename to src/sources/achievements_count.h index fc9ac388..5a03d96d 100644 --- a/src/sources/xbox/achievements_count.h +++ b/src/sources/achievements_count.h @@ -9,11 +9,11 @@ extern "C" { * @brief OBS source type that renders the total number of achievements for the current game. * * This module registers an OBS source that displays the total number of achievements - * available for the currently played Xbox game. + * available for the currently played game. */ /** - * @brief Register the "Xbox Achievements Count" source with OBS. + * @brief Register the "Achievements Count" source with OBS. * * Call once during plugin/module initialization. */ diff --git a/src/sources/common/achievement_cycle.c b/src/sources/common/achievement_cycle.c index 554e1b1a..0438c407 100644 --- a/src/sources/common/achievement_cycle.c +++ b/src/sources/common/achievement_cycle.c @@ -4,7 +4,9 @@ #include #include "common/achievement.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" + +#include /** Duration to show the last unlocked achievement (seconds). */ #define LAST_UNLOCKED_DISPLAY_DURATION 45.0f @@ -55,7 +57,7 @@ static bool g_initialized = false; /** * @brief Whether the session is fully ready (achievements fetched + icons prefetched). * - * Set to true by on_session_ready, reset to false by on_xbox_game_played. + * Set to true by on_session_ready, reset to false by on_game_played. * While false, achievement_cycle_tick and reset_display_cycle are no-ops so * the cycle does not start before all icons are available in the local cache. */ @@ -86,7 +88,7 @@ static void notify_subscribers(const achievement_t *achievement) { * * Finds the last unlocked achievement and resets all timers to start * a fresh display cycle. Makes a deep copy of the achievement to avoid - * dangling pointers when the xbox_monitor session changes. + * dangling pointers when the session changes. */ static void reset_display_cycle(void) { @@ -97,7 +99,7 @@ static void reset_display_cycle(void) { /* Free the old cached copy */ free_achievement(&g_last_unlocked); - const achievement_t *achievements = get_current_game_achievements(); + achievement_t *achievements = copy_achievement(monitoring_get_current_game_achievements()); /* Find the last unlocked achievement */ const achievement_t *latest_unlocked = find_latest_unlocked_achievement(achievements); @@ -105,6 +107,8 @@ static void reset_display_cycle(void) { g_last_unlocked = copy_achievement(latest_unlocked); } + free_achievement(&achievements); + /* Reset display cycle to show the last unlocked achievement */ g_display_phase = DISPLAY_PHASE_LAST_UNLOCKED; g_phase_timer = LAST_UNLOCKED_DISPLAY_DURATION; @@ -114,13 +118,13 @@ static void reset_display_cycle(void) { } // -------------------------------------------------------------------------------------------------------------------- -// Xbox monitor event handlers +// Monitoring service event handlers // -------------------------------------------------------------------------------------------------------------------- /** - * @brief Xbox monitor callback invoked when Xbox Live connection state changes. + * @brief Monitoring service callback invoked when connection state changes. * - * @param is_connected Whether the Xbox account is currently connected (unused). + * @param is_connected Whether the account is currently connected (unused). * @param error_message Optional error message if disconnected (unused). */ static void on_connection_changed(bool is_connected, const char *error_message) { @@ -139,7 +143,7 @@ static void on_connection_changed(bool is_connected, const char *error_message) * * @param game Currently played game information. */ -static void on_xbox_game_played(const game_t *game) { +static void on_game_played(const game_t *game) { UNUSED_PARAMETER(game); @@ -152,25 +156,17 @@ static void on_xbox_game_played(const game_t *game) { } /** - * @brief Xbox monitor callback invoked when achievement progress is updated. - * - * @param gamerscore Updated gamerscore snapshot (unused). - * @param progress Achievement progress details (unused). + * @brief Monitoring service callback invoked when achievements are updated. */ -static void on_achievements_progressed(const gamerscore_t *gamerscore, const achievement_progress_t *progress) { - - UNUSED_PARAMETER(gamerscore); - UNUSED_PARAMETER(progress); - +static void on_achievements_changed(void) { reset_display_cycle(); } /** - * @brief Xbox monitor callback invoked when the session is fully ready. + * @brief Monitoring service callback invoked when the session is fully ready. * - * Called from the prefetch background thread once all achievement icons have - * been downloaded to the local cache. This is the signal to start (or restart) - * the achievement display cycle. + * Called once all achievement icons have been downloaded to the local cache. + * This is the signal to start (or restart) the achievement display cycle. */ static void on_session_ready(void) { @@ -195,10 +191,10 @@ void achievement_cycle_init(void) { g_current_achievement = NULL; g_subscriber_count = 0; - xbox_subscribe_connected_changed(&on_connection_changed); - xbox_subscribe_game_played(&on_xbox_game_played); - xbox_subscribe_achievements_progressed(&on_achievements_progressed); - xbox_subscribe_session_ready(&on_session_ready); + monitoring_subscribe_connection_changed(&on_connection_changed); + monitoring_subscribe_game_played(&on_game_played); + monitoring_subscribe_achievements_changed(&on_achievements_changed); + monitoring_subscribe_session_ready(&on_session_ready); g_initialized = true; } @@ -209,7 +205,7 @@ void achievement_cycle_destroy(void) { return; } - /* TODO: Add xbox_unsubscribe_* functions if available */ + /* TODO: Add monitoring_unsubscribe_* functions if available */ /* Free the owned achievement copy */ free_achievement(&g_last_unlocked); @@ -265,8 +261,8 @@ void achievement_cycle_tick(float seconds) { return; } - /* Get the current achievements list */ - const achievement_t *achievements = get_current_game_achievements(); + /* Get the current achievements */ + achievement_t *achievements = copy_achievement(monitoring_get_current_game_achievements()); if (!achievements) { return; @@ -277,57 +273,53 @@ void achievement_cycle_tick(float seconds) { switch (g_display_phase) { case DISPLAY_PHASE_LAST_UNLOCKED: - /* Check if it's time to switch to locked achievements rotation */ if (g_phase_timer <= 0.0f) { obs_log(LOG_DEBUG, "Achievement Cycle: Switching to locked achievements rotation"); - /* Only switch if there are locked achievements to show */ if (count_locked_achievements(achievements) > 0) { g_display_phase = DISPLAY_PHASE_LOCKED_ROTATION; g_phase_timer = LOCKED_CYCLE_TOTAL_DURATION; g_locked_display_timer = LOCKED_ACHIEVEMENT_DISPLAY_DURATION; - /* Show the first random locked achievement */ const achievement_t *locked = get_random_locked_achievement(achievements); if (locked) { obs_log(LOG_DEBUG, "Achievement Cycle: Showing random locked achievement: %s", locked->name); - notify_subscribers(locked); + /* Store a copy so g_current_achievement survives beyond this tick */ + free_achievement(&g_last_unlocked); + g_last_unlocked = copy_achievement(locked); + notify_subscribers(g_last_unlocked); } else { obs_log(LOG_WARNING, "Achievement Cycle: No locked achievements to show"); } } else { obs_log(LOG_DEBUG, "Achievement Cycle: No locked achievements, keeping last unlocked"); - /* No locked achievements, keep showing last unlocked */ g_phase_timer = LAST_UNLOCKED_DISPLAY_DURATION; } } break; case DISPLAY_PHASE_LOCKED_ROTATION: - /* Update the locked achievement display timer */ g_locked_display_timer -= seconds; if (g_locked_display_timer <= 0.0f) { - /* Time for the next random locked achievement */ g_locked_display_timer = LOCKED_ACHIEVEMENT_DISPLAY_DURATION; const achievement_t *locked = get_random_locked_achievement(achievements); if (locked) { - notify_subscribers(locked); + free_achievement(&g_last_unlocked); + g_last_unlocked = copy_achievement(locked); + notify_subscribers(g_last_unlocked); } } - /* Check if the locked rotation phase is complete */ if (g_phase_timer <= 0.0f) { obs_log(LOG_DEBUG, "Achievement Cycle: Locked achievements rotation complete"); g_display_phase = DISPLAY_PHASE_LAST_UNLOCKED; g_phase_timer = LAST_UNLOCKED_DISPLAY_DURATION; - /* Switch back to our owned copy of the last unlocked achievement */ if (g_last_unlocked) { notify_subscribers(g_last_unlocked); } else { - /* If we don't have a cached copy, refresh it */ const achievement_t *latest_unlocked = find_latest_unlocked_achievement(achievements); if (latest_unlocked) { g_last_unlocked = copy_achievement(latest_unlocked); @@ -337,6 +329,8 @@ void achievement_cycle_tick(float seconds) { } break; } + + free_achievement(&achievements); } const achievement_t *achievement_cycle_get_current(void) { diff --git a/src/sources/common/achievement_cycle.h b/src/sources/common/achievement_cycle.h index 226fdef8..6971ce88 100644 --- a/src/sources/common/achievement_cycle.h +++ b/src/sources/common/achievement_cycle.h @@ -39,7 +39,7 @@ typedef void (*achievement_cycle_callback_t)(const achievement_t *achievement); /** * @brief Initialize the achievement cycle module. * - * Sets up the internal state and subscribes to Xbox monitor events. + * Sets up the internal state and subscribes to monitoring service events. * Call this once during plugin initialization. */ void achievement_cycle_init(void); diff --git a/src/sources/xbox/game_cover.c b/src/sources/game_cover.c similarity index 56% rename from src/sources/xbox/game_cover.c rename to src/sources/game_cover.c index ad44d26f..6e8b9631 100644 --- a/src/sources/xbox/game_cover.c +++ b/src/sources/game_cover.c @@ -1,17 +1,17 @@ -#include "sources/xbox/game_cover.h" +#include "sources/game_cover.h" /** * @file game_cover.c - * @brief OBS source that renders the cover art for the currently played Xbox game. + * @brief OBS source that renders the cover art for the currently played game. * * Responsibilities: - * - Subscribe to Xbox game-played events. - * - Download cover art when the game changes. + * - Subscribe to game-played events via the monitoring service. + * - Download cover art when the game changes (using the cover_url from game_t). * - Load the image into an OBS gs_texture_t on the graphics thread. * - Render the texture in the source's video_render callback. * * Threading notes: - * - Downloading happens on the calling thread of on_xbox_game_played() (currently + * - Downloading happens on the calling thread of on_game_played() (currently * synchronous). * - Texture creation/destruction must happen on the OBS graphics thread; this * file uses obs_enter_graphics()/obs_leave_graphics() to ensure that. @@ -19,12 +19,10 @@ #include #include -#include -#include "oauth/xbox-live.h" #include "sources/common/image_source.h" -#include "xbox/xbox_client.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" +#include "common/game.h" /** * @brief Global singleton cover cache. @@ -41,27 +39,32 @@ static image_t g_game_cover; /** * @brief Event handler called when a new game starts being played. * - * Fetches the cover-art URL for the given game and triggers a download. + * Uses the cover_url from the game_t to download the cover art. * * @param game Currently played game information. */ -static void on_xbox_game_played(const game_t *game) { +static void on_game_played(const game_t *game) { if (!game) { - obs_log(LOG_DEBUG, "[Game Cover] No game played"); + obs_log(LOG_INFO, "[Game Cover] No game played"); image_source_clear(&g_game_cover); return; } - obs_log(LOG_DEBUG, "[Game Cover] Playing game %s (%s)", game->title, game->id); + obs_log(LOG_INFO, "[Game Cover] Playing game %s (%s)", game->title, game->id); - char *game_cover_url = xbox_get_game_cover(game); - snprintf(g_game_cover.url, sizeof(g_game_cover.url), "%s", game_cover_url); + if (!game->cover_url || game->cover_url[0] == '\0') { + obs_log(LOG_INFO, "[Game Cover] No cover URL available"); + image_source_clear(&g_game_cover); + return; + } + + obs_log(LOG_INFO, "[Game Cover] Cover URL is %s", game->cover_url); + + snprintf(g_game_cover.url, sizeof(g_game_cover.url), "%s", game->cover_url); snprintf(g_game_cover.id, sizeof(g_game_cover.id), "%s", game->id); image_source_download(&g_game_cover); - - free_memory((void **)&game_cover_url); } // -------------------------------------------------------------------------------------------------------------------- @@ -85,7 +88,7 @@ static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Game Cover"; + return "Game Cover"; } /** @@ -93,7 +96,7 @@ static const char *source_get_name(void *unused) { * * @param settings OBS settings object (currently unused). * @param source OBS source instance. - * @return Newly allocated image_source_data_t. + * @return Newly allocated image_source_t. */ static void *on_source_create(obs_data_t *settings, obs_source_t *source) { @@ -156,92 +159,44 @@ static void on_source_video_render(void *data, gs_effect_t *effect) { } /** - * @brief OBS callback to construct the properties UI. - * - * Shows connection status, gamerscore, and the currently played game. - */ -static obs_properties_t *source_get_properties(void *data) { - - UNUSED_PARAMETER(data); - - /* Gets or refreshes the token */ - xbox_identity_t *xbox_identity = xbox_live_get_identity(); - - /* Lists all the UI components of the properties page */ - obs_properties_t *p = obs_properties_create(); - - if (xbox_identity != NULL) { - char status[4096]; - snprintf(status, 4096, "Connected to your xbox account as %s", xbox_identity->gamertag); - - int64_t gamerscore = 0; - xbox_fetch_gamerscore(&gamerscore); - - char gamerscore_text[4096]; - snprintf(gamerscore_text, 4096, "Gamerscore %" PRId64, gamerscore); - - obs_properties_add_text(p, "connected_status_info", status, OBS_TEXT_INFO); - obs_properties_add_text(p, "gamerscore_info", gamerscore_text, OBS_TEXT_INFO); - - const game_t *game = get_current_game(); - - if (game) { - char game_played[4096]; - snprintf(game_played, sizeof(game_played), "Playing %s (%s)", game->title, game->id); - obs_properties_add_text(p, "game_played", game_played, OBS_TEXT_INFO); - } - } else { - obs_properties_add_text(p, - "disconnected_status_info", - "You are not connected to your xbox account", - OBS_TEXT_INFO); - } - - free_identity(&xbox_identity); - - return p; -} - -/** - * @brief obs_source_info for the Xbox Game Cover source. + * @brief obs_source_info for the Game Cover source. */ -static struct obs_source_info xbox_game_cover_source_info = { - .id = "xbox_game_cover_source", - .type = OBS_SOURCE_TYPE_INPUT, - .output_flags = OBS_SOURCE_VIDEO, - .get_name = source_get_name, - .create = on_source_create, - .destroy = on_source_destroy, - .update = on_source_update, - .video_render = on_source_video_render, - .get_properties = source_get_properties, - .get_width = source_get_width, - .get_height = source_get_height, - .video_tick = NULL, +static struct obs_source_info game_cover_source_info = { + .id = "xbox_game_cover_source", + .type = OBS_SOURCE_TYPE_INPUT, + .output_flags = OBS_SOURCE_VIDEO, + .get_name = source_get_name, + .create = on_source_create, + .destroy = on_source_destroy, + .update = on_source_update, + .video_render = on_source_video_render, + .get_width = source_get_width, + .get_height = source_get_height, + .video_tick = NULL, }; /** * @brief Get a pointer to this source type's obs_source_info. */ -static const struct obs_source_info *xbox_game_cover_source_get(void) { - return &xbox_game_cover_source_info; +static const struct obs_source_info *game_cover_source_get(void) { + return &game_cover_source_info; } // -------------------------------------------------------------------------------------------------------------------- // Public functions // -------------------------------------------------------------------------------------------------------------------- -void xbox_game_cover_source_register(void) { +void game_cover_source_register(void) { snprintf(g_game_cover.display_name, sizeof(g_game_cover.display_name), "Game Cover"); g_game_cover.id[0] = '\0'; snprintf(g_game_cover.type, sizeof(g_game_cover.type), "game_cover"); - obs_register_source(xbox_game_cover_source_get()); + obs_register_source(game_cover_source_get()); - xbox_subscribe_game_played(&on_xbox_game_played); + monitoring_subscribe_game_played(&on_game_played); } -void xbox_game_cover_source_cleanup(void) { +void game_cover_source_cleanup(void) { image_source_destroy(&g_game_cover); } diff --git a/src/sources/game_cover.h b/src/sources/game_cover.h new file mode 100644 index 00000000..b562a519 --- /dev/null +++ b/src/sources/game_cover.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file game_cover.h + * @brief OBS source type that renders the cover art for the currently played game. + * + * This module registers an OBS source that displays cover art for the active + * game. It subscribes to the monitoring service and works with any integration + * (Xbox Live, RetroAchievements, etc.) through the common @c game_t type. + */ + +/** + * @brief Register the "Game Cover" source with OBS. + * + * Call once during plugin/module initialization. + */ +void game_cover_source_register(void); + +/** + * @brief Clean up resources allocated by the game cover source. + * + * Frees the global cover image cache and destroys associated textures. + * Should be called during plugin shutdown (obs_module_unload()). + */ +void game_cover_source_cleanup(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/sources/xbox/gamerpic.c b/src/sources/gamerpic.c similarity index 68% rename from src/sources/xbox/gamerpic.c rename to src/sources/gamerpic.c index ef360c75..c7c1f92d 100644 --- a/src/sources/xbox/gamerpic.c +++ b/src/sources/gamerpic.c @@ -1,13 +1,11 @@ -#include "sources/xbox/gamerpic.h" +#include "sources/gamerpic.h" #include #include -#include "oauth/xbox-live.h" #include "sources/common/image_source.h" #include "common/memory.h" -#include "xbox/xbox_client.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" /** * @brief Global singleton gamerpic cache. @@ -21,43 +19,25 @@ static image_t g_gamerpic; // Event handlers // -------------------------------------------------------------------------------------------------------------------- -/** - * @brief Xbox monitor callback invoked when connection state changes. - * - * When connected, this refreshes the gamerscore display. - * - * @param is_connected Whether the account is currently connected. - * @param error_message Optional error message if disconnected (ignored here). - */ -static void on_connection_changed(bool is_connected, const char *error_message) { +static void on_active_identity_changed(const identity_t *identity) { - UNUSED_PARAMETER(error_message); - - if (!is_connected) { - obs_log(LOG_DEBUG, "[Gamerpic] Not connected - clearing"); + if (!identity || !identity->avatar_url || identity->avatar_url[0] == '\0') { + obs_log(LOG_DEBUG, "[Gamerpic] No avatar URL - clearing"); image_source_clear(&g_gamerpic); return; } - obs_log(LOG_DEBUG, "[Gamerpic] Connected to Xbox Live - fetching Gamerpic URL %s", g_gamerpic.type); - - char *gamerpic_url = xbox_fetch_gamerpic(); - - if (!gamerpic_url || gamerpic_url[0] == '\0') { - obs_log(LOG_INFO, "[Gamerpic] No Gamerpic URL - clearing"); - image_source_clear(&g_gamerpic); - goto cleanup; - } - - if (strcasecmp(gamerpic_url, g_gamerpic.url) != 0) { - obs_log(LOG_DEBUG, "[Gamerpic] Gamerpic URL changed - downloading"); - snprintf(g_gamerpic.url, sizeof(g_gamerpic.url), "%s", gamerpic_url); - snprintf(g_gamerpic.id, sizeof(g_gamerpic.id), "%s", "default"); + if (strcasecmp(identity->avatar_url, g_gamerpic.url) != 0) { + obs_log(LOG_DEBUG, "[Gamerpic] Avatar URL changed - downloading"); + snprintf(g_gamerpic.url, sizeof(g_gamerpic.url), "%s", identity->avatar_url); + /* Use the identity name as the cache id so different integration avatars + * never share the same cache file. */ + snprintf(g_gamerpic.id, + sizeof(g_gamerpic.id), + "%s", + (identity->name && identity->name[0] != '\0') ? identity->name : "default"); image_source_download(&g_gamerpic); } - -cleanup: - free_memory((void **)&gamerpic_url); } // -------------------------------------------------------------------------------------------------------------------- @@ -81,7 +61,7 @@ static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Gamerpic"; + return "Gamerpic"; } /** @@ -160,30 +140,13 @@ static obs_properties_t *source_get_properties(void *data) { UNUSED_PARAMETER(data); - /* Gets or refreshes the token */ - xbox_identity_t *xbox_identity = xbox_live_get_identity(); - - /* Lists all the UI components of the properties page */ obs_properties_t *p = obs_properties_create(); - - if (xbox_identity != NULL) { - char status[4096]; - snprintf(status, 4096, "Connected to your xbox account as %s", xbox_identity->gamertag); - obs_properties_add_text(p, "connected_status_info", status, OBS_TEXT_INFO); - } else { - obs_properties_add_text(p, - "disconnected_status_info", - "You are not connected to your xbox account", - OBS_TEXT_INFO); - } - - free_identity(&xbox_identity); - + obs_properties_add_text(p, "info", "Displays the active user's profile picture.", OBS_TEXT_INFO); return p; } /** - * @brief obs_source_info for the Xbox Gamerpic source. + * @brief obs_source_info for the Gamerpic source. */ static struct obs_source_info xbox_gamerpic_source_info = { .id = "xbox_gamerpic_source", @@ -219,7 +182,7 @@ void xbox_gamerpic_source_register(void) { obs_register_source(xbox_gamerpic_source_get()); - xbox_subscribe_connected_changed(&on_connection_changed); + monitoring_subscribe_active_identity(on_active_identity_changed); } void xbox_gamerpic_source_cleanup(void) { diff --git a/src/sources/xbox/gamerpic.h b/src/sources/gamerpic.h similarity index 66% rename from src/sources/xbox/gamerpic.h rename to src/sources/gamerpic.h index 6b8ff5b1..84a41840 100644 --- a/src/sources/xbox/gamerpic.h +++ b/src/sources/gamerpic.h @@ -7,11 +7,11 @@ extern "C" { #endif /** - * @brief Register the "Xbox Gamerpic" OBS source. + * @brief Register the "Gamerpic" OBS source. * - * This source renders the currently authenticated user's Xbox gamerpic (avatar). - * The gamerpic is fetched via Xbox profile settings (GameDisplayPicRaw) and cached - * locally as a GPU texture. + * This source renders the currently authenticated user's gamerpic (avatar). + * The gamerpic URL is provided by the active integration and cached locally + * as a GPU texture. * * Call this once from the plugin/module entry point during OBS module load. */ diff --git a/src/sources/xbox/gamerscore.c b/src/sources/gamerscore.c similarity index 63% rename from src/sources/xbox/gamerscore.c rename to src/sources/gamerscore.c index 9a3759a1..72810618 100644 --- a/src/sources/xbox/gamerscore.c +++ b/src/sources/gamerscore.c @@ -1,24 +1,12 @@ -#include "sources/xbox/gamerscore.h" +#include "sources/gamerscore.h" /** * @file gamerscore.c - * @brief OBS source that renders the currently authenticated Xbox account's gamerscore. + * @brief OBS source that renders the active user's score. * - * This source displays a numeric gamerscore by drawing digits from a pre-baked - * font sheet (atlas). Digits are extracted as subregions from the atlas texture - * and drawn sequentially. - * - * Data flow: - * - The Xbox monitor notifies this module when connection state changes and/or - * achievement progress updates. - * - The module computes the latest gamerscore and stores it in a global. - * - During rendering, the current gamerscore is formatted to text and each - * digit is drawn from the font sheet texture. - * - * Threading notes: - * - Event handlers may be invoked from non-graphics threads. - * - Texture creation must happen on the OBS graphics thread; this file lazily - * initializes the texture in the video_render callback. + * Subscribes to the monitoring service's active-identity event so it works for + * both Xbox Live (gamerscore) and RetroAchievements (higher of hardcore vs + * softcore score, resolved by identity_from_retro()). */ #include "sources/common/text_source.h" @@ -28,74 +16,38 @@ #include #include "io/state.h" -#include "oauth/xbox-live.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" #define NO_FLIP 0 static char g_gamerscore[64]; static bool g_must_reload; -/** - * @brief Configuration for rendering digits from the font sheet. - * - * Stored as a module-global pointer and initialized during - * xbox_gamerscore_source_register(). - */ static gamerscore_configuration_t *g_default_configuration; /** - * @brief Recompute and store the latest gamerscore. - * - * @param gamerscore Gamerscore snapshot received from the Xbox monitor. - */ -static void update_gamerscore(const gamerscore_t *gamerscore) { - - int total_gamerscore = gamerscore_compute(gamerscore); - - // Computes the total gamerscore and activate the switch to reload the texture with the new number. - snprintf(g_gamerscore, sizeof(g_gamerscore), "%dG", total_gamerscore); - g_must_reload = true; - - obs_log(LOG_INFO, "[Gamerscore] Gamerscore is %" PRId64, total_gamerscore); -} - -/** - * @brief Xbox monitor callback invoked when connection state changes. - * - * When connected, this refreshes the gamerscore display. - * - * @param is_connected Whether the account is currently connected. - * @param error_message Optional error message if disconnected (ignored here). + * @brief Update the score display from the active identity. */ -static void on_connection_changed(bool is_connected, const char *error_message) { - - UNUSED_PARAMETER(error_message); +static void update_gamerscore(const identity_t *identity) { - if (!is_connected) { + if (!identity) { g_gamerscore[0] = '\0'; - g_must_reload = true; - return; + } else if (identity->source == IDENTITY_SOURCE_XBOX) { + snprintf(g_gamerscore, sizeof(g_gamerscore), "%u G", identity->score); + obs_log(LOG_INFO, "[Gamerscore] Xbox score: %uG", identity->score); + } else { + snprintf(g_gamerscore, sizeof(g_gamerscore), "%u", identity->score); + obs_log(LOG_INFO, "[Gamerscore] Retro score: %u Hardcore", identity->score); } - const gamerscore_t *gamerscore = get_current_gamerscore(); - - update_gamerscore(gamerscore); + g_must_reload = true; } /** - * @brief Xbox monitor callback invoked when achievements progress. - * - * Recomputes the gamerscore based on the updated snapshot. - * - * @param gamerscore Updated gamerscore snapshot. - * @param progress Achievement progress details (unused). + * @brief Monitoring service callback for active identity changes. */ -static void on_achievements_progressed(const gamerscore_t *gamerscore, const achievement_progress_t *progress) { - - UNUSED_PARAMETER(progress); - - update_gamerscore(gamerscore); +static void on_active_identity_changed(const identity_t *identity) { + update_gamerscore(identity); } // -------------------------------------------------------------------------------------------------------------------- @@ -107,7 +59,7 @@ static void on_achievements_progressed(const gamerscore_t *gamerscore, const ach * * @param settings Source settings (unused). * @param source OBS source instance. - * @return Newly allocated xbox_account_source_t. + * @return Newly allocated text_source_t. */ static void *on_source_create(obs_data_t *settings, obs_source_t *source) { @@ -219,11 +171,11 @@ static obs_properties_t *source_get_properties(void *data) { static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Gamerscore"; + return "Gamerscore"; } /** - * @brief obs_source_info describing the Xbox Gamerscore source. + * @brief obs_source_info describing the Gamerscore source. */ static struct obs_source_info xbox_gamerscore_source = { .id = "xbox_gamerscore_source", @@ -258,8 +210,7 @@ void xbox_gamerscore_source_register(void) { obs_register_source(xbox_source_get()); - xbox_subscribe_connected_changed(&on_connection_changed); - xbox_subscribe_achievements_progressed(&on_achievements_progressed); + monitoring_subscribe_active_identity(on_active_identity_changed); } void xbox_gamerscore_source_cleanup(void) { diff --git a/src/sources/xbox/gamerscore.h b/src/sources/gamerscore.h similarity index 63% rename from src/sources/xbox/gamerscore.h rename to src/sources/gamerscore.h index 721a8c6d..c6014235 100644 --- a/src/sources/xbox/gamerscore.h +++ b/src/sources/gamerscore.h @@ -6,14 +6,14 @@ extern "C" { /** * @file gamerscore.h - * @brief OBS source type that renders an Xbox user's gamerscore. + * @brief OBS source type that renders the active user's score. * - * This module registers an OBS source that displays the currently authenticated - * Xbox account's gamerscore. + * This module registers an OBS source that displays the score of the currently + * authenticated account (Xbox Live gamerscore or RetroAchievements score). */ /** - * @brief Register the "Xbox Gamerscore" source with OBS. + * @brief Register the "Gamerscore" source with OBS. * * Call once during plugin/module initialization. */ diff --git a/src/sources/xbox/gamertag.c b/src/sources/gamertag.c similarity index 67% rename from src/sources/xbox/gamertag.c rename to src/sources/gamertag.c index f4f1291b..22330f81 100644 --- a/src/sources/xbox/gamertag.c +++ b/src/sources/gamertag.c @@ -1,11 +1,11 @@ -#include "sources/xbox/gamertag.h" +#include "sources/gamertag.h" /** * @file gamertag.c - * @brief OBS source that displays the authenticated Xbox account's gamertag. + * @brief OBS source that displays the authenticated user's gamertag / display name. * - * Uses the text_source infrastructure for rendering and configuration management. - * Updates automatically when connection state changes via Xbox monitor callbacks. + * Subscribes to the monitoring service's active-identity event so it works for + * both Xbox Live and RetroAchievements sessions. */ #include "sources/common/text_source.h" @@ -14,8 +14,7 @@ #include #include "io/state.h" -#include "oauth/xbox-live.h" -#include "xbox/xbox_monitor.h" +#include "integrations/monitoring_service.h" /** Current gamertag text to display. */ static char g_gamertag[256]; @@ -27,32 +26,33 @@ static bool g_must_reload; static gamertag_configuration_t *g_configuration; /** - * @brief Update the gamertag display from Xbox identity. + * @brief Update the gamertag display from the active identity. + * + * When an identity becomes available the source fades in with the new name. + * When the identity is lost the source fades out to blank, preserving the + * previous name as the "current" text so the text_source transition system + * can fade it out gracefully before replacing it with an empty string. */ -static void update_gamertag(void) { - - xbox_identity_t *identity = xbox_live_get_identity(); - - if (!identity || !identity->gamertag) { - snprintf(g_gamertag, sizeof(g_gamertag), "Not connected"); +static void update_gamertag(const identity_t *identity) { + + if (!identity || !identity->name || identity->name[0] == '\0') { + /* Lost identity: only trigger a reload (fade to blank) if something + * was previously displayed. */ + if (g_gamertag[0] != '\0') { + g_gamertag[0] = '\0'; + g_must_reload = true; + } } else { - snprintf(g_gamertag, sizeof(g_gamertag), "%s", identity->gamertag); + snprintf(g_gamertag, sizeof(g_gamertag), "%s", identity->name); + g_must_reload = true; } - - g_must_reload = true; - - free_identity(&identity); } /** - * @brief Xbox monitor callback for connection state changes. + * @brief Monitoring service callback for active identity changes. */ -static void on_connection_changed(bool is_connected, const char *error_message) { - - UNUSED_PARAMETER(is_connected); - UNUSED_PARAMETER(error_message); - - update_gamertag(); +static void on_active_identity_changed(const identity_t *identity) { + update_gamertag(identity); } // -------------------------------------------------------------------------------------------------------------------- @@ -62,7 +62,11 @@ static void on_connection_changed(bool is_connected, const char *error_message) static void *on_source_create(obs_data_t *settings, obs_source_t *source) { UNUSED_PARAMETER(settings); - update_gamertag(); + /* Populate immediately with the current identity, so the source does not + * briefly show "Not connected" when added to a scene after the monitor + * has already connected. The subscription callback won't fire again for + * an already-established identity. */ + update_gamertag(monitoring_get_current_active_identity()); return text_source_create(source, "Gamertag"); } @@ -117,10 +121,10 @@ static obs_properties_t *source_get_properties(void *data) { static const char *source_get_name(void *unused) { UNUSED_PARAMETER(unused); - return "Xbox Gamertag"; + return "Gamertag"; } -/** OBS source type definition for Xbox Gamertag display. */ +/** OBS source type definition for the Gamertag display. */ static struct obs_source_info xbox_gamertag_source = { .id = "xbox_gamertag_source", .type = OBS_SOURCE_TYPE_INPUT, @@ -147,7 +151,7 @@ void xbox_gamertag_source_register(void) { obs_register_source(&xbox_gamertag_source); - xbox_subscribe_connected_changed(&on_connection_changed); + monitoring_subscribe_active_identity(on_active_identity_changed); } void xbox_gamertag_source_cleanup(void) { diff --git a/src/sources/xbox/gamertag.h b/src/sources/gamertag.h similarity index 63% rename from src/sources/xbox/gamertag.h rename to src/sources/gamertag.h index 13971cb1..2b66f3ae 100644 --- a/src/sources/xbox/gamertag.h +++ b/src/sources/gamertag.h @@ -6,14 +6,14 @@ extern "C" { /** * @file gamertag.h - * @brief OBS source type that renders an Xbox user's gamertag. + * @brief OBS source type that renders the active user's gamertag / display name. * - * This module registers an OBS source that displays the currently authenticated - * Xbox account's gamertag. + * This module registers an OBS source that displays the gamertag of the + * currently authenticated account (Xbox Live or RetroAchievements). */ /** - * @brief Register the "Xbox Gamertag" source with OBS. + * @brief Register the "Gamertag" source with OBS. * * Call once during plugin/module initialization. */ diff --git a/src/sources/xbox/game_cover.h b/src/sources/xbox/game_cover.h deleted file mode 100644 index 51c28a93..00000000 --- a/src/sources/xbox/game_cover.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @file game_cover.h - * @brief OBS source type that renders an Xbox game's cover art. - * - * This module registers an OBS source that can display cover art for the - * currently selected/active Xbox title. - */ - -/** - * @brief Register the "Xbox Game Cover" source with OBS. - * - * Call once during plugin/module initialization. - */ -void xbox_game_cover_source_register(void); - -/** - * @brief Clean up resources allocated by the game cover source. - * - * Frees the global cover image cache and destroys associated textures. - * Should be called during plugin shutdown (obs_module_unload()). - */ -void xbox_game_cover_source_cleanup(void); - -#ifdef __cplusplus -} -#endif diff --git a/src/text/parsers.c b/src/text/parsers.c index 2ac1f2da..526a13db 100644 --- a/src/text/parsers.c +++ b/src/text/parsers.c @@ -121,23 +121,24 @@ bool is_presence_message(const char *json_string) { return contains_node(json_string, "/presenceDetails"); } -game_t *parse_game(const char *json_string) { +char *parse_presence_game_id(const char *json_string) { - cJSON *json_root = NULL; - game_t *game = NULL; + cJSON *json_root = NULL; + char *game_id = NULL; if (!json_string || strlen(json_string) == 0) { return NULL; } + obs_log(LOG_WARNING, "GAME Received: %s", json_string); + json_root = cJSON_Parse(json_string); if (!json_root) { return NULL; } - char current_game_title[128] = ""; - char current_game_id[128] = ""; + char current_game_id[128] = ""; for (int detail_index = 0; detail_index < 3; detail_index++) { @@ -161,18 +162,6 @@ game_t *parse_game(const char *json_string) { obs_log(LOG_DEBUG, "Game at %d. Is game = %s", detail_index, is_game_value->valuestring); - /* Retrieve the game title and its ID */ - char game_title_key[512]; - snprintf(game_title_key, sizeof(game_title_key), "/presenceDetails/%d/presenceText", detail_index); - - cJSON *game_title_value = cJSONUtils_GetPointer(json_root, game_title_key); - - if (game_title_value->valuestring[0] == '\0') { - continue; - } - - obs_log(LOG_DEBUG, "Game title: %s %s", game_title_value->string, game_title_value->valuestring); - char game_id_key[512]; snprintf(game_id_key, sizeof(game_id_key), "/presenceDetails/%d/titleId", detail_index); @@ -180,7 +169,6 @@ game_t *parse_game(const char *json_string) { obs_log(LOG_DEBUG, "Game ID: %s %s", game_id_value->string, game_id_value->valuestring); - snprintf(current_game_title, sizeof(current_game_title), "%s", game_title_value->valuestring); snprintf(current_game_id, sizeof(current_game_id), "%s", game_id_value->valuestring); } @@ -189,27 +177,27 @@ game_t *parse_game(const char *json_string) { goto cleanup; } - obs_log(LOG_DEBUG, "Game is %s (%s)", current_game_title, current_game_id); + obs_log(LOG_DEBUG, "Game ID is %s", current_game_id); - game = bzalloc(sizeof(game_t)); - game->id = bstrdup(current_game_id); - game->title = bstrdup(current_game_title); + game_id = bstrdup(current_game_id); cleanup: free_json_memory((void **)&json_root); - return game; + return game_id; } -achievement_progress_t *parse_achievement_progress(const char *json_string) { +xbox_achievement_progress_t *parse_achievement_progress(const char *json_string) { - cJSON *json_root = NULL; - achievement_progress_t *achievement_progress = NULL; + cJSON *json_root = NULL; + xbox_achievement_progress_t *achievement_progress = NULL; if (!json_string || strlen(json_string) == 0) { return NULL; } + obs_log(LOG_WARNING, "Received: %s", json_string); + json_root = cJSON_Parse(json_string); if (!json_root) { @@ -264,21 +252,21 @@ achievement_progress_t *parse_achievement_progress(const char *json_string) { int64_t unlocked_timestamp = 0; if (!convert_iso8601_utc_to_unix(time_unlocked_node->valuestring, &unlocked_timestamp, &fraction)) { - obs_log(LOG_ERROR, "No time unlocked at %d", detail_index); + obs_log(LOG_ERROR, "No time unlocked at %d (received %s)", detail_index, time_unlocked_node->valuestring); continue; } - achievement_progress_t *progress = bzalloc(sizeof(achievement_progress_t)); - progress->service_config_id = bstrdup(current_service_config_id); - progress->id = bstrdup(id_node->valuestring); - progress->progress_state = bstrdup(progress_state_node->valuestring); - progress->unlocked_timestamp = unlocked_timestamp; - progress->next = NULL; + xbox_achievement_progress_t *progress = bzalloc(sizeof(xbox_achievement_progress_t)); + progress->service_config_id = bstrdup(current_service_config_id); + progress->id = bstrdup(id_node->valuestring); + progress->progress_state = bstrdup(progress_state_node->valuestring); + progress->unlocked_timestamp = unlocked_timestamp; + progress->next = NULL; if (!achievement_progress) { achievement_progress = progress; } else { - achievement_progress_t *last_progress = achievement_progress; + xbox_achievement_progress_t *last_progress = achievement_progress; while (last_progress->next) { last_progress = last_progress->next; } @@ -292,10 +280,10 @@ achievement_progress_t *parse_achievement_progress(const char *json_string) { return achievement_progress; } -achievement_t *parse_achievements(const char *json_string) { +xbox_achievement_t *parse_achievements(const char *json_string) { - cJSON *json_root = NULL; - achievement_t *achievements = NULL; + cJSON *json_root = NULL; + xbox_achievement_t *achievements = NULL; if (!json_string || strlen(json_string) == 0) { return NULL; @@ -317,7 +305,7 @@ achievement_t *parse_achievements(const char *json_string) { break; } - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = id; achievement->service_config_id = get_node_string(json_root, achievement_index, "serviceConfigId"); achievement->name = get_node_string(json_root, achievement_index, "name"); @@ -330,7 +318,7 @@ achievement_t *parse_achievements(const char *json_string) { achievement->icon_url = get_node_string(json_root, achievement_index, "mediaAssets/0/url"); /* Reads the media assets */ - media_asset_t *media_assets = NULL; + xbox_media_asset_t *media_assets = NULL; for (int media_asset_index = 0;; media_asset_index++) { @@ -344,19 +332,18 @@ achievement_t *parse_achievements(const char *json_string) { cJSON *media_asset_url_node = cJSONUtils_GetPointer(json_root, media_asset_url_key); if (!media_asset_url_node) { - /* There is nothing more */ obs_log(LOG_DEBUG, "No more media asset at %d/%d", achievement_index, media_asset_index); break; } - media_asset_t *media_asset = bzalloc(sizeof(media_asset_t)); - media_asset->url = bstrdup(media_asset_url_node->valuestring); - media_asset->next = NULL; + xbox_media_asset_t *media_asset = bzalloc(sizeof(xbox_media_asset_t)); + media_asset->url = bstrdup(media_asset_url_node->valuestring); + media_asset->next = NULL; if (!media_assets) { media_assets = media_asset; } else { - media_asset_t *last_media_asset = media_assets; + xbox_media_asset_t *last_media_asset = media_assets; while (last_media_asset->next) { last_media_asset = last_media_asset->next; } @@ -367,7 +354,7 @@ achievement_t *parse_achievements(const char *json_string) { achievement->media_assets = media_assets; /* Reads the rewards */ - reward_t *rewards = NULL; + xbox_reward_t *rewards = NULL; for (int reward_index = 0;; reward_index++) { @@ -381,13 +368,11 @@ achievement_t *parse_achievements(const char *json_string) { cJSON *reward_type_node = cJSONUtils_GetPointer(json_root, reward_type_key); if (!reward_type_node) { - /* There is nothing more */ obs_log(LOG_DEBUG, "No more reward at %d/%d", achievement_index, reward_index); break; } if (!reward_type_node->type || strcasecmp(reward_type_node->valuestring, "Gamerscore") != 0) { - /* Ignores the non-gamerscore reward */ obs_log(LOG_DEBUG, "Not a Gamerscore reward at %d/%d", achievement_index, reward_index); continue; } @@ -406,13 +391,13 @@ achievement_t *parse_achievements(const char *json_string) { continue; } - reward_t *reward = bzalloc(sizeof(reward_t)); - reward->value = bstrdup(reward_value_node->valuestring); + xbox_reward_t *reward = bzalloc(sizeof(xbox_reward_t)); + reward->value = bstrdup(reward_value_node->valuestring); if (!rewards) { rewards = reward; } else { - reward_t *last_reward = rewards; + xbox_reward_t *last_reward = rewards; while (last_reward->next) { last_reward = last_reward->next; } @@ -432,7 +417,7 @@ achievement_t *parse_achievements(const char *json_string) { if (!achievements) { achievements = achievement; } else { - achievement_t *last_achievement = achievements; + xbox_achievement_t *last_achievement = achievements; while (last_achievement->next) { last_achievement = last_achievement->next; } diff --git a/src/text/parsers.h b/src/text/parsers.h index 9e70b61b..71ca29f5 100644 --- a/src/text/parsers.h +++ b/src/text/parsers.h @@ -52,15 +52,15 @@ bool is_achievement_message(const char *json_string); * @param json_string NUL-terminated JSON string. * @return Newly allocated game_t on success; NULL on failure. */ -game_t *parse_game(const char *json_string); +char *parse_presence_game_id(const char *json_string); /** * @brief Parse achievement progress information from a JSON message. * * @param json_string NUL-terminated JSON string. - * @return Newly allocated achievement_progress_t on success; NULL on failure. + * @return Newly allocated xbox_achievement_progress_t on success; NULL on failure. */ -achievement_progress_t *parse_achievement_progress(const char *json_string); +xbox_achievement_progress_t *parse_achievement_progress(const char *json_string); /** * @brief Parse achievements information from a JSON message. @@ -69,9 +69,9 @@ achievement_progress_t *parse_achievement_progress(const char *json_string); * container describing multiple achievements. * * @param json_string NUL-terminated JSON string. - * @return Newly allocated achievement_t on success; NULL on failure. + * @return Newly allocated xbox_achievement_t on success; NULL on failure. */ -achievement_t *parse_achievements(const char *json_string); +xbox_achievement_t *parse_achievements(const char *json_string); #ifdef __cplusplus } diff --git a/src/ui/xbox_account_config.cpp b/src/ui/xbox_account_config.cpp index 98c4a6e0..a4c15dc4 100644 --- a/src/ui/xbox_account_config.cpp +++ b/src/ui/xbox_account_config.cpp @@ -18,7 +18,7 @@ #include extern "C" { -#include "xbox/account_manager.h" +#include "integrations/xbox/account_manager.h" } namespace { diff --git a/test/stubs/integrations/retro_achievements_monitor_stub.c b/test/stubs/integrations/retro_achievements_monitor_stub.c new file mode 100644 index 00000000..20cb2ed0 --- /dev/null +++ b/test/stubs/integrations/retro_achievements_monitor_stub.c @@ -0,0 +1,100 @@ +/** + * @file retro_achievements_monitor_stub.c + * @brief Stub implementations for retro_achievements_monitor.h. + * + * Stores the callbacks that monitoring_service.c installs via + * retro_achievements_subscribe_*() and exposes mock_retro_monitor_fire_*() + * helpers so tests can drive those callbacks directly. + */ + +#include "test/stubs/integrations/retro_achievements_monitor_stub.h" +#include "integrations/retro-achievements/retro_achievements_monitor.h" + +/* ------------------------------------------------------------------------- + * Registered callbacks + * ---------------------------------------------------------------------- */ + +static on_retro_connection_changed_t s_cb_connection_changed = NULL; +static on_retro_user_t s_cb_user = NULL; +static on_retro_no_user_t s_cb_no_user = NULL; +static on_retro_game_playing_t s_cb_game_playing = NULL; +static on_retro_no_game_t s_cb_no_game = NULL; +static on_retro_achievements_t s_cb_achievements = NULL; + +/* ------------------------------------------------------------------------- + * retro_achievements_subscribe_* β€” called by monitoring_service + * ---------------------------------------------------------------------- */ + +void retro_achievements_subscribe_connection_changed(on_retro_connection_changed_t callback) { + s_cb_connection_changed = callback; +} + +void retro_achievements_subscribe_user(on_retro_user_t callback) { + s_cb_user = callback; +} + +void retro_achievements_subscribe_no_user(on_retro_no_user_t callback) { + s_cb_no_user = callback; +} + +void retro_achievements_subscribe_game_playing(on_retro_game_playing_t callback) { + s_cb_game_playing = callback; +} + +void retro_achievements_subscribe_no_game(on_retro_no_game_t callback) { + s_cb_no_game = callback; +} + +void retro_achievements_subscribe_achievements(on_retro_achievements_t callback) { + s_cb_achievements = callback; +} + +/* ------------------------------------------------------------------------- + * Lifecycle stubs β€” no-ops in tests + * ---------------------------------------------------------------------- */ + +bool retro_achievements_monitor_start(void) { + return true; +} +void retro_achievements_monitor_stop(void) {} +bool retro_achievements_monitor_is_active(void) { + return false; +} + +/* ------------------------------------------------------------------------- + * Mock control helpers + * ---------------------------------------------------------------------- */ + +void mock_retro_monitor_fire_connection_changed(bool connected, const char *error_message) { + if (s_cb_connection_changed) + s_cb_connection_changed(connected, error_message); +} + +void mock_retro_monitor_fire_user(const retro_user_t *user) { + if (s_cb_user) + s_cb_user(user); +} + +void mock_retro_monitor_fire_no_user(void) { + if (s_cb_no_user) + s_cb_no_user(); +} + +void mock_retro_monitor_fire_game_playing(const retro_game_t *game) { + if (s_cb_game_playing) + s_cb_game_playing(game); +} + +void mock_retro_monitor_fire_no_game(void) { + if (s_cb_no_game) + s_cb_no_game(); +} + +void mock_retro_monitor_reset(void) { + s_cb_connection_changed = NULL; + s_cb_user = NULL; + s_cb_no_user = NULL; + s_cb_game_playing = NULL; + s_cb_no_game = NULL; + s_cb_achievements = NULL; +} diff --git a/test/stubs/integrations/retro_achievements_monitor_stub.h b/test/stubs/integrations/retro_achievements_monitor_stub.h new file mode 100644 index 00000000..97a37471 --- /dev/null +++ b/test/stubs/integrations/retro_achievements_monitor_stub.h @@ -0,0 +1,49 @@ +#pragma once + +/** + * @file retro_achievements_monitor_stub.h + * @brief Test controls for the retro_achievements_monitor stub. + * + * Lets tests drive monitoring_service.c's retro callbacks without a real + * WebSocket connection to the RetroArch server. + */ + +#include "integrations/retro-achievements/retro_achievements_monitor.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Simulate a RetroAchievements connection-changed event. + */ +void mock_retro_monitor_fire_connection_changed(bool connected, const char *error_message); + +/** + * @brief Simulate a "user" message arriving from the RetroArch server. + */ +void mock_retro_monitor_fire_user(const retro_user_t *user); + +/** + * @brief Simulate a "no_user" message arriving from the RetroArch server. + */ +void mock_retro_monitor_fire_no_user(void); + +/** + * @brief Simulate a "game_playing" message arriving from the RetroArch server. + */ +void mock_retro_monitor_fire_game_playing(const retro_game_t *game); + +/** + * @brief Simulate a "no_game" message arriving from the RetroArch server. + */ +void mock_retro_monitor_fire_no_game(void); + +/** + * @brief Reset all stub state. Call from tearDown(). + */ +void mock_retro_monitor_reset(void); + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/integrations/xbox_monitor_stub.c b/test/stubs/integrations/xbox_monitor_stub.c new file mode 100644 index 00000000..1e6fab52 --- /dev/null +++ b/test/stubs/integrations/xbox_monitor_stub.c @@ -0,0 +1,148 @@ +/** + * @file xbox_monitor_stub.c + * @brief Stub implementations for xbox_monitor.h, xbox_client.h, and the + * state_get_xbox_identity() / xbox_fetch_gamerpic() calls made by + * monitoring_service.c. + * + * Registers/stores the callbacks that monitoring_service.c installs via + * xbox_subscribe_*() and lets tests fire them on demand via the mock_* helpers. + */ + +#include "test/stubs/integrations/xbox_monitor_stub.h" + +#include "integrations/xbox/xbox_monitor.h" +#include "integrations/xbox/xbox_client.h" +#include "integrations/xbox/entities/xbox_identity.h" +#include "io/state.h" +#include "common/game.h" +#include "common/gamerscore.h" + +#include +#include + +/* ------------------------------------------------------------------------- + * Registered callbacks (set by monitoring_service via xbox_subscribe_*) + * ---------------------------------------------------------------------- */ + +static on_xbox_connection_changed_t s_cb_connection_changed = NULL; +static on_xbox_game_played_t s_cb_game_played = NULL; +static on_xbox_achievements_progressed_t s_cb_achievements_progressed = NULL; +static on_xbox_session_ready_t s_cb_session_ready = NULL; + +/* Identity returned by state_get_xbox_identity() */ +static xbox_identity_t *s_xbox_identity = NULL; + +/* ------------------------------------------------------------------------- + * xbox_subscribe_* β€” called by monitoring_service during monitoring_start() + * ---------------------------------------------------------------------- */ + +void xbox_subscribe_connected_changed(on_xbox_connection_changed_t callback) { + s_cb_connection_changed = callback; +} + +void xbox_subscribe_game_played(on_xbox_game_played_t callback) { + s_cb_game_played = callback; +} + +void xbox_subscribe_achievements_progressed(on_xbox_achievements_progressed_t callback) { + s_cb_achievements_progressed = callback; +} + +void xbox_subscribe_session_ready(on_xbox_session_ready_t callback) { + s_cb_session_ready = callback; +} + +/* ------------------------------------------------------------------------- + * Lifecycle stubs β€” no-ops in tests + * ---------------------------------------------------------------------- */ + +bool xbox_monitoring_start(void) { + return true; +} +void xbox_monitoring_stop(void) {} +bool xbox_monitoring_is_active(void) { + return false; +} + +/* ------------------------------------------------------------------------- + * xbox_monitor.h data getters β€” return NULL / 0 in tests + * ---------------------------------------------------------------------- */ + +const gamerscore_t *get_current_gamerscore(void) { + return NULL; +} +const game_t *get_current_game(void) { + return NULL; +} +const xbox_achievement_t *get_current_game_achievements(void) { + return NULL; +} + +/* ------------------------------------------------------------------------- + * xbox_client.h stubs + * ---------------------------------------------------------------------- */ + +bool xbox_fetch_gamerscore(int64_t *out_gamerscore) { + if (out_gamerscore) + *out_gamerscore = 0; + return false; +} + +game_t *xbox_get_current_game(void) { + return NULL; +} + +xbox_achievement_t *xbox_get_game_achievements(const game_t *game) { + (void)game; + return NULL; +} + +char *xbox_get_game_cover(const game_t *game) { + (void)game; + return NULL; +} + +char *xbox_fetch_gamerpic(void) { + return NULL; +} + +/* ------------------------------------------------------------------------- + * state_get_xbox_identity() β€” returns the identity set by the test + * ---------------------------------------------------------------------- */ + +xbox_identity_t *state_get_xbox_identity(void) { + if (!s_xbox_identity) + return NULL; + + /* Return a fresh copy each time β€” monitoring_service.c calls free_identity() + * on the pointer it receives, so we must not return the original. */ + return copy_xbox_identity(s_xbox_identity); +} + +/* ------------------------------------------------------------------------- + * Mock control helpers + * ---------------------------------------------------------------------- */ + +void mock_xbox_monitor_set_identity(xbox_identity_t *identity) { + /* Free the previously stored identity before replacing it. */ + free_identity(&s_xbox_identity); + s_xbox_identity = identity; /* Takes ownership. */ +} + +void mock_xbox_monitor_fire_connection_changed(bool connected, const char *error_message) { + if (s_cb_connection_changed) + s_cb_connection_changed(connected, error_message); +} + +void mock_xbox_monitor_fire_game_played(const game_t *game) { + if (s_cb_game_played) + s_cb_game_played(game); +} + +void mock_xbox_monitor_reset(void) { + free_identity(&s_xbox_identity); + s_cb_connection_changed = NULL; + s_cb_game_played = NULL; + s_cb_achievements_progressed = NULL; + s_cb_session_ready = NULL; +} diff --git a/test/stubs/integrations/xbox_monitor_stub.h b/test/stubs/integrations/xbox_monitor_stub.h new file mode 100644 index 00000000..ca265516 --- /dev/null +++ b/test/stubs/integrations/xbox_monitor_stub.h @@ -0,0 +1,49 @@ +#pragma once + +/** + * @file xbox_monitor_stub.h + * @brief Test controls for the xbox_monitor / xbox_client stubs. + * + * Call these from setUp / individual tests to drive the monitoring_service + * callbacks without a real WebSocket connection or HTTP client. + */ + +#include "common/game.h" +#include "integrations/xbox/xbox_monitor.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Set the xbox_identity that state_get_xbox_identity() will return. + * + * The stub takes ownership of the provided identity; pass NULL to make the + * stub return NULL (simulates "not logged in"). + */ +void mock_xbox_monitor_set_identity(xbox_identity_t *identity); + +/** + * @brief Simulate an Xbox connection-changed event. + * + * Invokes the callback registered via xbox_subscribe_connected_changed(). + */ +void mock_xbox_monitor_fire_connection_changed(bool connected, const char *error_message); + +/** + * @brief Simulate an Xbox game-played event. + * + * Invokes the callback registered via xbox_subscribe_game_played(). + */ +void mock_xbox_monitor_fire_game_played(const game_t *game); + +/** + * @brief Reset all stub state (callbacks, stored identity, etc.). + * + * Call from tearDown() to prevent state leaking between tests. + */ +void mock_xbox_monitor_reset(void); + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/io/cache_stub.c b/test/stubs/io/cache_stub.c index d6661ae3..6fc58e19 100644 --- a/test/stubs/io/cache_stub.c +++ b/test/stubs/io/cache_stub.c @@ -1,12 +1,5 @@ #include "io/cache.h" -void cache_build_path(const char *type, const char *id, char *out_path, size_t path_size) { - (void)type; - (void)id; - (void)out_path; - (void)path_size; -} - bool cache_download(const char *url, const char *type, const char *id, char *out_path, size_t path_size) { (void)url; (void)type; diff --git a/test/stubs/obs-module.h b/test/stubs/obs-module.h index 290ff59f..3c808499 100644 --- a/test/stubs/obs-module.h +++ b/test/stubs/obs-module.h @@ -4,3 +4,8 @@ /* Include the memory functions */ #include + +/* OBS utility macro used throughout the plugin source. */ +#ifndef UNUSED_PARAMETER +#define UNUSED_PARAMETER(param) (void)(param) +#endif diff --git a/test/stubs/xbox/xbox_client.h b/test/stubs/xbox/xbox_client.h index 64e6e837..e097d3be 100644 --- a/test/stubs/xbox/xbox_client.h +++ b/test/stubs/xbox/xbox_client.h @@ -8,7 +8,7 @@ extern "C" { #endif -void mock_xbox_client_set_achievements(achievement_t *achievements); +void mock_xbox_client_set_achievements(xbox_achievement_t *achievements); void mock_xbox_client_reset(void); /* Stub for xbox_fetch_gamerscore - does nothing in unit tests */ @@ -22,7 +22,7 @@ static inline game_t *xbox_get_current_game(void) { return NULL; } -achievement_t *xbox_get_game_achievements(const game_t *game); +xbox_achievement_t *xbox_get_game_achievements(const game_t *game); /* Stub for xbox_get_game_cover - returns NULL in unit tests */ static inline char *xbox_get_game_cover(const game_t *game) { diff --git a/test/stubs/xbox/xbox_client_stub.c b/test/stubs/xbox/xbox_client_stub.c index 43e8d859..b40b4bcd 100644 --- a/test/stubs/xbox/xbox_client_stub.c +++ b/test/stubs/xbox/xbox_client_stub.c @@ -1,8 +1,8 @@ #include "test/stubs/xbox/xbox_client.h" -static achievement_t *mock_achievements = NULL; +static xbox_achievement_t *mock_achievements = NULL; -void mock_xbox_client_set_achievements(achievement_t *achievements) { +void mock_xbox_client_set_achievements(xbox_achievement_t *achievements) { mock_achievements = achievements; } @@ -10,7 +10,7 @@ void mock_xbox_client_reset(void) { mock_achievements = NULL; } -achievement_t *xbox_get_game_achievements(const game_t *game) { +xbox_achievement_t *xbox_get_game_achievements(const game_t *game) { (void)game; return mock_achievements; } diff --git a/test/test_monitoring_service.c b/test/test_monitoring_service.c new file mode 100644 index 00000000..f7a0e0dd --- /dev/null +++ b/test/test_monitoring_service.c @@ -0,0 +1,501 @@ +/** + * @file test_monitoring_service.c + * @brief Unit tests for monitoring_service.c β€” active-identity notifications. + * + * The xbox_monitor, xbox_client, and retro_achievements_monitor are fully + * stubbed. Tests drive their callbacks directly via the mock_* helpers and + * assert that on_monitoring_active_identity_changed_t subscribers receive the + * correct identity (or NULL) in every scenario. + * + * Scenarios covered: + * Xbox: + * 1. Xbox connects without a game β†’ no identity notification + * 2. Xbox connects, game played β†’ identity notified + * 3. Xbox game played before connection β†’ NULL (identity not cached yet) + * 4. Xbox disconnects β†’ NULL notified + * 5. Xbox disconnects with retro game active β†’ retro identity takes over + * 6. Xbox game stops (game_played NULL) β†’ active identity becomes NULL + * + * RetroAchievements: + * 7. Retro user + game β†’ retro identity notified + * 8. Retro game before user β†’ NULL, then identity once user arrives + * 9. Retro user before game β†’ NULL, then identity once game arrives + * 10. Retro no_game β†’ active identity callback fires with NULL + * 11. Retro no_user (game active) β†’ active identity callback fires with NULL + * 12. Retro game stops (no_game) after active identity β†’ NULL notified + * + * Last-game-source priority: + * 13. Xbox game then retro game β†’ retro identity active + * 14. Retro game then Xbox game β†’ Xbox identity active + * 15. Xbox game, then retro game, then Xbox game again β†’ Xbox identity active + * 16. Retro no_user while Xbox game also active β†’ Xbox identity takes over + */ + +#include "unity.h" + +#include "test/stubs/integrations/xbox_monitor_stub.h" +#include "test/stubs/integrations/retro_achievements_monitor_stub.h" + +#include "integrations/monitoring_service.h" +#include "integrations/xbox/entities/xbox_identity.h" +#include "common/game.h" +#include "common/token.h" + +#include +#include + +/* ------------------------------------------------------------------------- + * Helpers β€” build lightweight test fixtures + * ---------------------------------------------------------------------- */ + +static xbox_identity_t *make_xbox_identity(const char *gamertag) { + token_t *token = bzalloc(sizeof(token_t)); + token->value = bstrdup("test-token"); + token->expires = 9999999999LL; + + xbox_identity_t *id = bzalloc(sizeof(xbox_identity_t)); + id->gamertag = bstrdup(gamertag); + id->xid = bstrdup("xuid-123"); + id->uhs = bstrdup("uhs-abc"); + id->token = token; + return id; +} + +static game_t *make_xbox_game(const char *id, const char *title) { + game_t *g = bzalloc(sizeof(game_t)); + g->id = bstrdup(id); + g->title = bstrdup(title); + return g; +} + +static void fill_retro_user(retro_user_t *u, const char *username, const char *display_name) { + memset(u, 0, sizeof(*u)); + strncpy(u->username, username, sizeof(u->username) - 1); + strncpy(u->display_name, display_name, sizeof(u->display_name) - 1); + u->score = 1500; + u->score_softcore = 800; +} + +static void fill_retro_game(retro_game_t *g, const char *id, const char *name) { + memset(g, 0, sizeof(*g)); + strncpy(g->game_id, id, sizeof(g->game_id) - 1); + strncpy(g->game_name, name, sizeof(g->game_name) - 1); + strncpy(g->console_name, "SNES", sizeof(g->console_name) - 1); +} + +/* ------------------------------------------------------------------------- + * Subscriber spy + * ---------------------------------------------------------------------- */ + +static int s_identity_cb_count = 0; +static const identity_t *s_last_identity = NULL; + +static void on_identity_changed(const identity_t *identity) { + s_identity_cb_count++; + s_last_identity = identity; +} + +/* ------------------------------------------------------------------------- + * setUp / tearDown + * ---------------------------------------------------------------------- */ + +void setUp(void) { + s_identity_cb_count = 0; + s_last_identity = NULL; + + mock_xbox_monitor_reset(); + mock_retro_monitor_reset(); + + monitoring_start(); + monitoring_subscribe_active_identity(on_identity_changed); + + /* Consume the immediate callback fired by monitoring_subscribe_active_identity + * (returns current identity, which is NULL at start). */ + s_identity_cb_count = 0; + s_last_identity = NULL; +} + +void tearDown(void) { + monitoring_stop(); + mock_xbox_monitor_reset(); + mock_retro_monitor_reset(); +} + +/* ========================================================================= + * Xbox tests + * ====================================================================== */ + +/* 1. Xbox connects without a game β†’ no identity notification yet */ +static void monitoring_subscribe_active_identity__xbox_connected_without_game__identity_not_notified(void) { + + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + + mock_xbox_monitor_fire_connection_changed(true, NULL); + + TEST_ASSERT_EQUAL_INT(0, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); +} + +/* 2. Xbox connects, then game played β†’ identity is still Xbox */ +static void monitoring_subscribe_active_identity__xbox_connected_and_game_played__xbox_identity_notified(void) { + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + + s_identity_cb_count = 0; + + game_t *game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(game); + free_game(&game); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("MasterChief", s_last_identity->name); +} + +/* 3. Game played before connection (no identity cached yet) β†’ NULL */ +static void monitoring_subscribe_active_identity__xbox_game_played_before_connect__null_notified(void) { + /* No identity set in the stub β€” state_get_xbox_identity() returns NULL. */ + game_t *game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(game); + free_game(&game); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); +} + +/* 4. Xbox disconnects β†’ NULL notified */ +static void monitoring_subscribe_active_identity__xbox_disconnected__null_notified(void) { + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + s_identity_cb_count = 0; + + mock_xbox_monitor_fire_connection_changed(false, NULL); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); +} + +/* 5. Xbox disconnects while a retro game is active β†’ retro identity takes over */ +static void +monitoring_subscribe_active_identity__xbox_disconnected_with_retro_game_active__retro_identity_notified(void) { + /* Set up retro first */ + retro_user_t retro_user; + fill_retro_user(&retro_user, "RetroUser", "Retro User"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&retro_user); + + retro_game_t retro_game; + fill_retro_game(&retro_game, "rom-crc-01", "Super Metroid"); + mock_retro_monitor_fire_game_playing(&retro_game); + + /* Then Xbox connects and plays */ + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + + game_t *xbox_game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(xbox_game); + free_game(&xbox_game); + + s_identity_cb_count = 0; + + /* Xbox disconnects β†’ retro should become active */ + mock_xbox_monitor_fire_connection_changed(false, NULL); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Retro User", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_RETRO, s_last_identity->source); +} + +/* 6. Xbox game stops (game_played NULL) β†’ active identity becomes NULL */ +static void monitoring_subscribe_active_identity__xbox_no_game__null_notified(void) { + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + + game_t *game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(game); + free_game(&game); + + s_identity_cb_count = 0; + + /* Xbox signals no game by firing game_played with NULL */ + mock_xbox_monitor_fire_game_played(NULL); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); +} + +/* ========================================================================= + * RetroAchievements tests + * ====================================================================== */ + +/* 7. Retro user + game β†’ retro identity notified */ +static void monitoring_subscribe_active_identity__retro_user_and_game__retro_identity_notified(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_RETRO, s_last_identity->source); +} + +/* 8. Retro game arrives before user: NULL while waiting, correct identity after */ +static void monitoring_subscribe_active_identity__retro_game_before_user__null_then_identity_notified(void) { + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + /* No user yet β†’ NULL */ + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); + + s_identity_cb_count = 0; + + /* User arrives β†’ identity now known */ + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_user(&user); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); +} + +/* 9. Retro user arrives before game: no notification until the game arrives */ +static void monitoring_subscribe_active_identity__retro_user_before_game__identity_notified_on_game(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + /* User arrived but no game yet β†’ no active-identity notification */ + TEST_ASSERT_EQUAL_INT(0, s_identity_cb_count); + + /* Game arrives β†’ identity notified */ + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); +} + +/* 10. Retro no_game β†’ active identity becomes NULL and callback fires */ +static void monitoring_subscribe_active_identity__retro_no_game__null_returned(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + s_identity_cb_count = 0; + mock_retro_monitor_fire_no_game(); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); + TEST_ASSERT_NULL(monitoring_get_current_active_identity()); +} + +/* 11. Retro no_user (while game is active) β†’ active identity becomes NULL and callback fires */ +static void monitoring_subscribe_active_identity__retro_no_user__null_returned(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + s_identity_cb_count = 0; + mock_retro_monitor_fire_no_user(); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); + TEST_ASSERT_NULL(monitoring_get_current_active_identity()); +} + +/* 12. Retro game stops (no_game) after having an active identity β†’ NULL notified */ +static void monitoring_subscribe_active_identity__retro_no_game_after_active__null_notified(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + retro_game_t game; + fill_retro_game(&game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&game); + + /* Verify identity was active */ + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); + + s_identity_cb_count = 0; + s_last_identity = NULL; + + mock_retro_monitor_fire_no_game(); + + /* After no_game the callback must have fired with NULL */ + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NULL(s_last_identity); + TEST_ASSERT_NULL(monitoring_get_current_active_identity()); +} + +/* 16. Retro no_user while Xbox game also active β†’ Xbox identity takes over */ +static void monitoring_subscribe_active_identity__retro_no_user_with_xbox_game_active__xbox_identity_notified(void) { + /* Both Xbox and retro connected and playing */ + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + game_t *xbox_game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(xbox_game); + free_game(&xbox_game); + + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + retro_game_t retro_game; + fill_retro_game(&retro_game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&retro_game); + + /* Retro is now the last game source */ + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); + + s_identity_cb_count = 0; + + /* Retro loses user β†’ Xbox identity should become active */ + mock_retro_monitor_fire_no_user(); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("MasterChief", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_XBOX, s_last_identity->source); +} + +/* ========================================================================= + * Last-game-source priority tests + * ====================================================================== */ + +/* 13. Xbox game then retro game β†’ retro identity is active */ +static void monitoring_subscribe_active_identity__xbox_game_then_retro_game__retro_identity_active(void) { + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + + game_t *xbox_game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(xbox_game); + free_game(&xbox_game); + + /* Now retro connects */ + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + s_identity_cb_count = 0; + + retro_game_t retro_game; + fill_retro_game(&retro_game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&retro_game); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("Octelys", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_RETRO, s_last_identity->source); +} + +/* 14. Retro game then Xbox game β†’ Xbox identity is active */ +static void monitoring_subscribe_active_identity__retro_game_then_xbox_game__xbox_identity_active(void) { + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + + retro_game_t retro_game; + fill_retro_game(&retro_game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&retro_game); + + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + + s_identity_cb_count = 0; + + game_t *xbox_game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(xbox_game); + free_game(&xbox_game); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("MasterChief", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_XBOX, s_last_identity->source); +} + +/* 15. Xbox β†’ retro β†’ Xbox again β†’ Xbox identity is active */ +static void monitoring_subscribe_active_identity__xbox_retro_xbox__xbox_identity_active(void) { + mock_xbox_monitor_set_identity(make_xbox_identity("MasterChief")); + mock_xbox_monitor_fire_connection_changed(true, NULL); + game_t *xbox_game = make_xbox_game("game-1", "Halo Infinite"); + mock_xbox_monitor_fire_game_played(xbox_game); + free_game(&xbox_game); + + retro_user_t user; + fill_retro_user(&user, "octelys", "Octelys"); + mock_retro_monitor_fire_connection_changed(true, NULL); + mock_retro_monitor_fire_user(&user); + retro_game_t retro_game; + fill_retro_game(&retro_game, "crc-abc", "Chrono Trigger"); + mock_retro_monitor_fire_game_playing(&retro_game); + + s_identity_cb_count = 0; + + game_t *xbox_game2 = make_xbox_game("game-2", "Forza Horizon 5"); + mock_xbox_monitor_fire_game_played(xbox_game2); + free_game(&xbox_game2); + + TEST_ASSERT_EQUAL_INT(1, s_identity_cb_count); + TEST_ASSERT_NOT_NULL(s_last_identity); + TEST_ASSERT_EQUAL_STRING("MasterChief", s_last_identity->name); + TEST_ASSERT_EQUAL_INT(IDENTITY_SOURCE_XBOX, s_last_identity->source); +} + +/* ------------------------------------------------------------------------- + * Test runner + * ---------------------------------------------------------------------- */ + +int main(void) { + UNITY_BEGIN(); + + /* Xbox */ + RUN_TEST(monitoring_subscribe_active_identity__xbox_connected_without_game__identity_not_notified); + RUN_TEST(monitoring_subscribe_active_identity__xbox_connected_and_game_played__xbox_identity_notified); + RUN_TEST(monitoring_subscribe_active_identity__xbox_game_played_before_connect__null_notified); + RUN_TEST(monitoring_subscribe_active_identity__xbox_disconnected__null_notified); + RUN_TEST(monitoring_subscribe_active_identity__xbox_disconnected_with_retro_game_active__retro_identity_notified); + RUN_TEST(monitoring_subscribe_active_identity__xbox_no_game__null_notified); + + /* RetroAchievements */ + RUN_TEST(monitoring_subscribe_active_identity__retro_user_and_game__retro_identity_notified); + RUN_TEST(monitoring_subscribe_active_identity__retro_game_before_user__null_then_identity_notified); + RUN_TEST(monitoring_subscribe_active_identity__retro_user_before_game__identity_notified_on_game); + RUN_TEST(monitoring_subscribe_active_identity__retro_no_game__null_returned); + RUN_TEST(monitoring_subscribe_active_identity__retro_no_user__null_returned); + RUN_TEST(monitoring_subscribe_active_identity__retro_no_game_after_active__null_notified); + + /* Priority */ + RUN_TEST(monitoring_subscribe_active_identity__xbox_game_then_retro_game__retro_identity_active); + RUN_TEST(monitoring_subscribe_active_identity__retro_game_then_xbox_game__xbox_identity_active); + RUN_TEST(monitoring_subscribe_active_identity__xbox_retro_xbox__xbox_identity_active); + RUN_TEST(monitoring_subscribe_active_identity__retro_no_user_with_xbox_game_active__xbox_identity_notified); + + return UNITY_END(); +} diff --git a/test/test_parsers.c b/test/test_parsers.c index d8ea56a9..98f0eea4 100644 --- a/test/test_parsers.c +++ b/test/test_parsers.c @@ -132,7 +132,7 @@ static void parse_game__message_is_null_null_returned(void) { const char *message = NULL; // Act. - game_t *actual = parse_game(message); + char *actual = parse_presence_game_id(message); // Assert. TEST_ASSERT_NULL(actual); @@ -143,7 +143,7 @@ static void parse_game__message_is_empty_null_returned(void) { const char *message = " "; // Act. - game_t *actual = parse_game(message); + char *actual = parse_presence_game_id(message); // Assert. TEST_ASSERT_NULL(actual); @@ -154,7 +154,7 @@ static void parse_game__message_is_not_json_null_returned(void) { const char *message = "this-is-not-a-json"; // Act. - game_t *actual = parse_game(message); + char *actual = parse_presence_game_id(message); // Assert. TEST_ASSERT_NULL(actual); @@ -166,7 +166,7 @@ static void parse_game__message_is_achievement_null_returned(void) { "{\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"progression\":[{\"id\":\"1\",\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"100\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"progressState\":\"Achieved\",\"timeUnlocked\":\"2026-01-18T02:48:21.707Z\"}],\"contractVersion\":1}"; // Act. - game_t *actual = parse_game(message); + char *actual = parse_presence_game_id(message); // Assert. TEST_ASSERT_NULL(actual); @@ -178,12 +178,11 @@ static void parse_game__message_is_presence_game_returned(void) { "{\"devicetype\":\"XboxOne\",\"titleid\":0,\"string1\":\"The Outer Worlds 2\",\"string2\":\"\",\"presenceState\":\"Online\",\"presenceText\":\"The Outer Worlds 2\",\"presenceDetails\":[{\"isBroadcasting\":false,\"device\":\"Scarlett\",\"presenceText\":\"Accueil\",\"state\":\"Active\",\"titleId\":\"750323071\",\"isGame\":false,\"isPrimary\":false,\"richPresenceText\":\"\"},{\"isBroadcasting\":false,\"device\":\"Scarlett\",\"presenceText\":\"The Outer Worlds 2\",\"state\":\"Active\",\"titleId\":\"1879711255\",\"isGame\":true,\"isPrimary\":true,\"richPresenceText\":\"\"}],\"xuid\":2533274953419891}"; // Act. - game_t *actual = parse_game(message); + char *actual = parse_presence_game_id(message); // Assert. TEST_ASSERT_NOT_NULL(actual); - TEST_ASSERT_EQUAL_STRING(actual->id, "1879711255"); - TEST_ASSERT_EQUAL_STRING(actual->title, "The Outer Worlds 2"); + TEST_ASSERT_EQUAL_STRING("1879711255", actual); } // Test parse_achievement @@ -193,7 +192,7 @@ static void parse_achievements_progress__message_is_null_null_returned(void) { const char *message = NULL; // Act. - achievement_progress_t *actual = parse_achievement_progress(message); + xbox_achievement_progress_t *actual = parse_achievement_progress(message); // Assert. TEST_ASSERT_NULL(actual); @@ -204,7 +203,7 @@ static void parse_achievements_progress__message_is_empty_null_returned(void) { const char *message = " "; // Act. - achievement_progress_t *actual = parse_achievement_progress(message); + xbox_achievement_progress_t *actual = parse_achievement_progress(message); // Assert. TEST_ASSERT_NULL(actual); @@ -215,7 +214,7 @@ static void parse_achievements_progress__message_is_not_json_null_returned(void) const char *message = "this-is-not-a-json"; // Act. - achievement_progress_t *actual = parse_achievement_progress(message); + xbox_achievement_progress_t *actual = parse_achievement_progress(message); // Assert. TEST_ASSERT_NULL(actual); @@ -227,7 +226,7 @@ static void parse_achievements_progress__message_is_achievement_achievement_retu "{\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"progression\":[{\"id\":\"1\",\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"100\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"progressState\":\"Achieved\",\"timeUnlocked\":\"2026-01-18T02:48:21.707Z\"}],\"contractVersion\":1}"; // Act. - achievement_progress_t *actual = parse_achievement_progress(message); + xbox_achievement_progress_t *actual = parse_achievement_progress(message); // Assert. TEST_ASSERT_NOT_NULL(actual); @@ -244,7 +243,7 @@ static void parse_achievements_progress__message_is_multiple_achievements_achiev "{\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"progression\":[{\"id\":\"1\",\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"100\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"progressState\":\"Achieved\",\"timeUnlocked\":\"2026-01-18T02:48:21.707Z\"}, {\"id\":\"2\",\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"100\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"progressState\":\"NotAchieved\",\"timeUnlocked\":\"2026-01-18T02:48:21.707Z\"}],\"contractVersion\":1}"; // Act. - achievement_progress_t *actual = parse_achievement_progress(message); + xbox_achievement_progress_t *actual = parse_achievement_progress(message); // Assert. TEST_ASSERT_NOT_NULL(actual); @@ -265,7 +264,7 @@ static void parse_achievements__message_is_one_achievement_achievement_returned( "{\"achievements\":[{\"id\":\"1\",\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"name\":\"Daddy's Glasses\",\"titleAssociations\":[{\"name\":\"My Friend Peppa Pig\",\"id\":2037558339}],\"progressState\":\"Achieved\",\"progression\":{\"requirements\":[],\"timeUnlocked\":\"2026-01-18T02:48:21.7070000Z\"},\"mediaAssets\":[{\"name\":\"cf486b2a-3a9e-4c14-b18c-c91e0bb56926\",\"type\":\"Icon\",\"url\":\"https://images-eds-ssl.xboxlive.com/image?url=27S1DHqE.cHkmFg4nspsdzttpqR9mABLoi_h264Ah_brT_74D18wvss1Tpl1Hv0V.ZRAXkfWjJILaiyZZyI_J2paDrXdC_1Gly_3Cnd9yC7IDl0y2ssMo_dvyQ_OhHyuW60ck5614OfHrmzXJvVaS2vM4efPU6iwu2_vBB1TeAE-\"}],\"platforms\":[\"XboxOne\"],\"isSecret\":false,\"description\":\"You found Daddy Pig's Glasses.\",\"lockedDescription\":\"Find Daddy Pig's Glasses.\",\"productId\":\"00000000-0000-0000-0000-00007972ac43\",\"achievementType\":\"Persistent\",\"participationType\":\"Individual\",\"timeWindow\":null,\"rewards\":[{\"name\":null,\"description\":null,\"value\":\"80\",\"type\":\"Gamerscore\",\"mediaAsset\":null,\"valueType\":\"Int\"}],\"estimatedTime\":\"00:00:00\",\"deeplink\":\"\",\"isRevoked\":false}]}"; // Act. - achievement_t *actual = parse_achievements(message); + xbox_achievement_t *actual = parse_achievements(message); // Assert. TEST_ASSERT_NOT_NULL(actual); @@ -288,13 +287,13 @@ static void parse_achievements__message_is_multiple_achievements_achievements_re "{\"achievements\":[{\"id\":\"1\",\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"name\":\"Daddy's Glasses\",\"titleAssociations\":[{\"name\":\"My Friend Peppa Pig\",\"id\":2037558339}],\"progressState\":\"Achieved\",\"progression\":{\"requirements\":[],\"timeUnlocked\":\"2026-01-18T02:48:21.7070000Z\"},\"mediaAssets\":[{\"name\":\"cf486b2a-3a9e-4c14-b18c-c91e0bb56926\",\"type\":\"Icon\",\"url\":\"https://images-eds-ssl.xboxlive.com/image?url=27S1DHqE.cHkmFg4nspsdzttpqR9mABLoi_h264Ah_brT_74D18wvss1Tpl1Hv0V.ZRAXkfWjJILaiyZZyI_J2paDrXdC_1Gly_3Cnd9yC7IDl0y2ssMo_dvyQ_OhHyuW60ck5614OfHrmzXJvVaS2vM4efPU6iwu2_vBB1TeAE-\"}],\"platforms\":[\"XboxOne\"],\"isSecret\":false,\"description\":\"You found Daddy Pig's Glasses.\",\"lockedDescription\":\"Find Daddy Pig's Glasses.\",\"productId\":\"00000000-0000-0000-0000-00007972ac43\",\"achievementType\":\"Persistent\",\"participationType\":\"Individual\",\"timeWindow\":null,\"rewards\":[{\"name\":null,\"description\":null,\"value\":\"80\",\"type\":\"Gamerscore\",\"mediaAsset\":null,\"valueType\":\"Int\"}],\"estimatedTime\":\"00:00:00\",\"deeplink\":\"\",\"isRevoked\":false},{\"id\":\"2\",\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"name\":\"Where's Mr. Dinosaur?\",\"titleAssociations\":[{\"name\":\"My Friend Peppa Pig\",\"id\":2037558339}],\"progressState\":\"NotStarted\",\"progression\":{\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"0\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"timeUnlocked\":\"0001-01-01T00:00:00.0000000Z\"},\"mediaAssets\":[{\"name\":\"09f94026-8896-4c8a-9b0c-aeb6371e88f0\",\"type\":\"Icon\",\"url\":\"https://images-eds-ssl.xboxlive.com/image?url=27S1DHqE.cHkmFg4nspsdzokISnshkl.YcYqCmweQJubIDDIVJtokZHSoEQgyASVwuVT1yj8cEV8HdUg07CxZIU7xq2U11afQQ26YbPJi4Hr0GTE81qqxgULNGGK4HLbQoUFccQ4orGzYT5WJdvS3Rj.19DADjcNoFcU9ugzoEk-\"}],\"platforms\":[\"XboxOne\"],\"isSecret\":false,\"description\":\"You recovered Mr. Dinosaur for George.\",\"lockedDescription\":\"Recover Mr. Dinosaur for George.\",\"productId\":\"00000000-0000-0000-0000-00007972ac43\",\"achievementType\":\"Persistent\",\"participationType\":\"Individual\",\"timeWindow\":null,\"rewards\":[{\"name\":null,\"description\":null,\"value\":\"80\",\"type\":\"Gamerscore\",\"mediaAsset\":null,\"valueType\":\"Int\"}],\"estimatedTime\":\"00:00:00\",\"deeplink\":\"\",\"isRevoked\":false},{\"id\":\"3\",\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"name\":\"Whose tracks are these?\",\"titleAssociations\":[{\"name\":\"My Friend Peppa Pig\",\"id\":2037558339}],\"progressState\":\"NotStarted\",\"progression\":{\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"0\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"timeUnlocked\":\"0001-01-01T00:00:00.0000000Z\"},\"mediaAssets\":[{\"name\":\"f0045535-229f-43fa-a597-7221cc75a49e\",\"type\":\"Icon\",\"url\":\"https://images-eds-ssl.xboxlive.com/image?url=27S1DHqE.cHkmFg4nspsd5XrL8tQY.MwWYIrfIlaoTapO0RHdDXslvnCBWfl8yoo0ZWDVpcYOKq2azXdVdxQiCKgnAZuGvvtQ33u632vocTNfQkynPR2EgVhYK51rjukn1CH232.4s4mJ859BihrO4wC3sc9NFfV.qv9ykMyqyM-\"}],\"platforms\":[\"XboxOne\"],\"isSecret\":false,\"description\":\"You followed the tracks in the forest and find out who left them.\",\"lockedDescription\":\"Follow the tracks in the forest and find out who left them.\",\"productId\":\"00000000-0000-0000-0000-00007972ac43\",\"achievementType\":\"Persistent\",\"participationType\":\"Individual\",\"timeWindow\":null,\"rewards\":[{\"name\":null,\"description\":null,\"value\":\"80\",\"type\":\"Gamerscore\",\"mediaAsset\":null,\"valueType\":\"Int\"}],\"estimatedTime\":\"00:00:00\",\"deeplink\":\"\",\"isRevoked\":false},{\"id\":\"4\",\"serviceConfigId\":\"00000000-0000-0000-0000-00007972ac43\",\"name\":\"The Best Snowman Ever!\",\"titleAssociations\":[{\"name\":\"My Friend Peppa Pig\",\"id\":2037558339}],\"progressState\":\"NotStarted\",\"progression\":{\"requirements\":[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"current\":\"0\",\"target\":\"100\",\"operationType\":\"Sum\",\"valueType\":\"Integer\",\"ruleParticipationType\":\"Individual\"}],\"timeUnlocked\":\"0001-01-01T00:00:00.0000000Z\"},\"mediaAssets\":[{\"name\":\"06131097-0d93-4b3c-9bf3-2df074c9d7da\",\"type\":\"Icon\",\"url\":\"https://images-eds-ssl.xboxlive.com/image?url=27S1DHqE.cHkmFg4nspsdwidOMhIK6i8yoxIzzG_hp2nj0JqLJgmdEkolMM7aQLA6ffb.9TCmrB5mYexWdu59xwWID84Gu9yiP6yhpgX2ARtT.uvKjV2V51TSqfyiLsH0JAUQBXTmVBXAqK0Q0dc4iGFdJhGBdEm7vzWVyTtrzs-\"}],\"platforms\":[\"XboxOne\"],\"isSecret\":false,\"description\":\"You built a snowman in Snowy Mountain.\",\"lockedDescription\":\"Build a snowman in Snowy Mountain.\",\"productId\":\"00000000-0000-0000-0000-00007972ac43\",\"achievementType\":\"Persistent\",\"participationType\":\"Individual\",\"timeWindow\":null,\"rewards\":[{\"name\":null,\"description\":null,\"value\":\"80\",\"type\":\"Gamerscore\",\"mediaAsset\":null,\"valueType\":\"Int\"}],\"estimatedTime\":\"00:00:00\",\"deeplink\":\"\",\"isRevoked\":false}],\"pagingInfo\":{\"continuationToken\":null,\"totalRecords\":11}}"; // Act. - achievement_t *actual = parse_achievements(message); + xbox_achievement_t *actual = parse_achievements(message); // Assert. TEST_ASSERT_NOT_NULL(actual); - achievement_t *current_achievement = actual; - int achievements_count = 0; + xbox_achievement_t *current_achievement = actual; + int achievements_count = 0; while (current_achievement != NULL) { achievements_count++; current_achievement = current_achievement->next; diff --git a/test/test_types.c b/test/test_types.c index 942338f5..16dd6e26 100644 --- a/test/test_types.c +++ b/test/test_types.c @@ -255,13 +255,13 @@ static void free_gamerscore__gamerscore_is_null__null_gamerscore_returned(void) static void free_gamerscore__gamerscore_is_not_null__null_gamerscore_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_2->value = 200; - unlocked_achievement_2->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_2->value = 200; + unlocked_achievement_2->next = NULL; - unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_1->value = 100; - unlocked_achievement_1->next = unlocked_achievement_2; + xbox_unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_1->value = 100; + unlocked_achievement_1->next = unlocked_achievement_2; gamerscore_t *gamerscore = bzalloc(sizeof(gamerscore_t)); gamerscore->base_value = 1000; @@ -300,13 +300,13 @@ static void copy_gamerscore__gamerscore_is_null__null_copy_returned(void) { static void copy_gamerscore__gamerscore_is_not_null__copy_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_2->value = 200; - unlocked_achievement_2->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_2->value = 200; + unlocked_achievement_2->next = NULL; - unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_1->value = 100; - unlocked_achievement_1->next = unlocked_achievement_2; + xbox_unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_1->value = 100; + unlocked_achievement_1->next = unlocked_achievement_2; gamerscore_t *gamerscore = bzalloc(sizeof(gamerscore_t)); gamerscore->base_value = 1000; @@ -353,10 +353,10 @@ static void copy_gamerscore__no_unlocked_achievements__base_value_returned(void) static void copy_gamerscore__one_unlocked_achievement__total_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement->id = bstrdup("achievement-id"); - unlocked_achievement->value = 200; - unlocked_achievement->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement->id = bstrdup("achievement-id"); + unlocked_achievement->value = 200; + unlocked_achievement->next = NULL; gamerscore_t *gamerscore = bzalloc(sizeof(gamerscore_t)); gamerscore->base_value = 400; @@ -371,15 +371,15 @@ static void copy_gamerscore__one_unlocked_achievement__total_returned(void) { static void copy_gamerscore__two_unlocked_achievements__total_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_2->id = bstrdup("achievement-id-2"); - unlocked_achievement_2->value = 200; - unlocked_achievement_2->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_2->id = bstrdup("achievement-id-2"); + unlocked_achievement_2->value = 200; + unlocked_achievement_2->next = NULL; - unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_1->id = bstrdup("achievement-id-1"); - unlocked_achievement_1->value = 100; - unlocked_achievement_1->next = unlocked_achievement_2; + xbox_unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_1->id = bstrdup("achievement-id-1"); + unlocked_achievement_1->value = 100; + unlocked_achievement_1->next = unlocked_achievement_2; gamerscore_t *gamerscore = bzalloc(sizeof(gamerscore_t)); gamerscore->base_value = 400; @@ -394,66 +394,66 @@ static void copy_gamerscore__two_unlocked_achievements__total_returned(void) { // Tests unlocked_achievement.c -static void free_unlocked_achievement__unlocked_achievement_is_null__null_unlocked_achievement_returned(void) { +static void xbox_free_unlocked_achievement__unlocked_achievement_is_null__null_unlocked_achievement_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement = NULL; + xbox_unlocked_achievement_t *unlocked_achievement = NULL; // Act. - free_unlocked_achievement(&unlocked_achievement); + xbox_free_unlocked_achievement(&unlocked_achievement); // Assert. TEST_ASSERT_NULL(unlocked_achievement); } -static void free_unlocked_achievement__unlocked_achievement_is_not_null__null_unlocked_achievement_returned(void) { +static void xbox_free_unlocked_achievement__unlocked_achievement_is_not_null__null_unlocked_achievement_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_2->id = bstrdup("achievement-id-2"); - unlocked_achievement_2->value = 200; - unlocked_achievement_2->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_2->id = bstrdup("achievement-id-2"); + unlocked_achievement_2->value = 200; + unlocked_achievement_2->next = NULL; - unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_1->id = bstrdup("achievement-id-1"); - unlocked_achievement_1->value = 100; - unlocked_achievement_1->next = unlocked_achievement_2; + xbox_unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_1->id = bstrdup("achievement-id-1"); + unlocked_achievement_1->value = 100; + unlocked_achievement_1->next = unlocked_achievement_2; // Act. - free_unlocked_achievement(&unlocked_achievement_1); + xbox_free_unlocked_achievement(&unlocked_achievement_1); // Assert. TEST_ASSERT_NULL(unlocked_achievement_1); - /* The `free_unlocked_achievement` cannot set other references to `NULL` so + /* The `xbox_free_unlocked_achievement` cannot set other references to `NULL` so * the C codebase should never hold-on to these achievements. */ TEST_ASSERT_NOT_NULL(unlocked_achievement_2); } -static void copy_unlocked_achievement__unlocked_achievement_is_null__null_copy_returned(void) { +static void xbox_copy_unlocked_achievement__unlocked_achievement_is_null__null_copy_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement = NULL; + xbox_unlocked_achievement_t *unlocked_achievement = NULL; // Act. - const unlocked_achievement_t *copy = copy_unlocked_achievement(unlocked_achievement); + const xbox_unlocked_achievement_t *copy = xbox_copy_unlocked_achievement(unlocked_achievement); // Assert. TEST_ASSERT_NULL(copy); } -static void copy_unlocked_achievement__unlocked_achievement_is_not_null__copy_returned(void) { +static void xbox_copy_unlocked_achievement__unlocked_achievement_is_not_null__copy_returned(void) { // Arrange. - unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_2->id = bstrdup("achievement-id-2"); - unlocked_achievement_2->value = 200; - unlocked_achievement_2->next = NULL; + xbox_unlocked_achievement_t *unlocked_achievement_2 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_2->id = bstrdup("achievement-id-2"); + unlocked_achievement_2->value = 200; + unlocked_achievement_2->next = NULL; - unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(unlocked_achievement_t)); - unlocked_achievement_1->id = bstrdup("achievement-id-1"); - unlocked_achievement_1->value = 100; - unlocked_achievement_1->next = unlocked_achievement_2; + xbox_unlocked_achievement_t *unlocked_achievement_1 = bzalloc(sizeof(xbox_unlocked_achievement_t)); + unlocked_achievement_1->id = bstrdup("achievement-id-1"); + unlocked_achievement_1->value = 100; + unlocked_achievement_1->next = unlocked_achievement_2; // Act. - const unlocked_achievement_t *copy = copy_unlocked_achievement(unlocked_achievement_1); + const xbox_unlocked_achievement_t *copy = xbox_copy_unlocked_achievement(unlocked_achievement_1); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -469,10 +469,10 @@ static void copy_unlocked_achievement__unlocked_achievement_is_not_null__copy_re static void free_reward__reward_is_null__null_reward_returned(void) { // Arrange. - reward_t *reward = NULL; + xbox_reward_t *reward = NULL; // Act. - free_reward(&reward); + xbox_free_reward(&reward); // Assert. TEST_ASSERT_NULL(reward); @@ -480,12 +480,12 @@ static void free_reward__reward_is_null__null_reward_returned(void) { static void free_reward__one_reward___null_reward_returned(void) { // Arrange. - reward_t *reward = bzalloc(sizeof(reward_t)); - reward->value = bstrdup("1000"); - reward->next = NULL; + xbox_reward_t *reward = bzalloc(sizeof(xbox_reward_t)); + reward->value = bstrdup("1000"); + reward->next = NULL; // Act. - free_reward(&reward); + xbox_free_reward(&reward); // Assert. TEST_ASSERT_NULL(reward); @@ -493,16 +493,16 @@ static void free_reward__one_reward___null_reward_returned(void) { static void free_reward__two_rewards___null_reward_returned(void) { // Arrange. - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; // Act. - free_reward(&reward1); + xbox_free_reward(&reward1); // Assert. TEST_ASSERT_NULL(reward1); @@ -510,10 +510,10 @@ static void free_reward__two_rewards___null_reward_returned(void) { static void copy_reward__reward_is_null__null_copy_returned(void) { // Arrange. - reward_t *reward = NULL; + xbox_reward_t *reward = NULL; // Act. - const reward_t *copy = copy_reward(reward); + const xbox_reward_t *copy = xbox_copy_reward(reward); // Assert. TEST_ASSERT_NULL(copy); @@ -521,12 +521,12 @@ static void copy_reward__reward_is_null__null_copy_returned(void) { static void copy_reward__one_reward__copy_returned(void) { // Arrange. - reward_t *reward = bzalloc(sizeof(reward_t)); - reward->value = bstrdup("1000"); - reward->next = NULL; + xbox_reward_t *reward = bzalloc(sizeof(xbox_reward_t)); + reward->value = bstrdup("1000"); + reward->next = NULL; // Act. - const reward_t *copy = copy_reward(reward); + const xbox_reward_t *copy = xbox_copy_reward(reward); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -536,16 +536,16 @@ static void copy_reward__one_reward__copy_returned(void) { static void copy_reward__two_rewards__copy_returned(void) { // Arrange. - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; // Act. - const reward_t *copy = copy_reward(reward1); + const xbox_reward_t *copy = xbox_copy_reward(reward1); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -557,10 +557,10 @@ static void copy_reward__two_rewards__copy_returned(void) { static void free_media_asset__media_asset_is_null__null_media_asset_returned(void) { // Arrange. - media_asset_t *media_asset = NULL; + xbox_media_asset_t *media_asset = NULL; // Act. - free_media_asset(&media_asset); + xbox_free_media_asset(&media_asset); // Assert. TEST_ASSERT_NULL(media_asset); @@ -568,11 +568,11 @@ static void free_media_asset__media_asset_is_null__null_media_asset_returned(voi static void free_media_asset__one_media_asset__null_media_asset_returned(void) { // Arrange. - media_asset_t *media_asset = bzalloc(sizeof(media_asset_t)); - media_asset->url = bstrdup("https://www.example.com/image.png"); + xbox_media_asset_t *media_asset = bzalloc(sizeof(xbox_media_asset_t)); + media_asset->url = bstrdup("https://www.example.com/image.png"); // Act. - free_media_asset(&media_asset); + xbox_free_media_asset(&media_asset); // Assert. TEST_ASSERT_NULL(media_asset); @@ -580,15 +580,15 @@ static void free_media_asset__one_media_asset__null_media_asset_returned(void) { static void free_media_asset__two_media_assets__null_media_asset_returned(void) { // Arrange. - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; // Act. - free_media_asset(&media_asset1); + xbox_free_media_asset(&media_asset1); // Assert. TEST_ASSERT_NULL(media_asset1); @@ -596,10 +596,10 @@ static void free_media_asset__two_media_assets__null_media_asset_returned(void) static void copy_media_asset__media_asset_is_null__null_copy_returned(void) { // Arrange. - media_asset_t *media_asset = NULL; + xbox_media_asset_t *media_asset = NULL; // Act. - media_asset_t *copy = copy_media_asset(media_asset); + xbox_media_asset_t *copy = xbox_copy_media_asset(media_asset); // Assert. TEST_ASSERT_NULL(copy); @@ -607,11 +607,11 @@ static void copy_media_asset__media_asset_is_null__null_copy_returned(void) { static void copy_media_asset__one_media_asset__copy_returned(void) { // Arrange. - media_asset_t *media_asset = bzalloc(sizeof(media_asset_t)); - media_asset->url = bstrdup("https://www.example.com/image.png"); + xbox_media_asset_t *media_asset = bzalloc(sizeof(xbox_media_asset_t)); + media_asset->url = bstrdup("https://www.example.com/image.png"); // Act. - const media_asset_t *copy = copy_media_asset(media_asset); + const xbox_media_asset_t *copy = xbox_copy_media_asset(media_asset); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -621,15 +621,15 @@ static void copy_media_asset__one_media_asset__copy_returned(void) { static void copy_media_asset__two_media_assets__copy_returned(void) { // Arrange. - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; // Act. - const media_asset_t *copy = copy_media_asset(media_asset1); + const xbox_media_asset_t *copy = xbox_copy_media_asset(media_asset1); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -641,10 +641,10 @@ static void copy_media_asset__two_media_assets__copy_returned(void) { static void free_achievement__achievement_is_null__null_achievement_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - free_achievement(&achievement); + xbox_free_achievement(&achievement); // Assert. TEST_ASSERT_NULL(achievement); @@ -652,22 +652,22 @@ static void free_achievement__achievement_is_null__null_achievement_returned(voi static void free_achievement__one_achievement__null_achievement_returned(void) { // Arrange. - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->service_config_id = bstrdup("service-config-id"); achievement->name = bstrdup("Achievement Name"); @@ -680,7 +680,7 @@ static void free_achievement__one_achievement__null_achievement_returned(void) { achievement->next = NULL; // Act. - free_achievement(&achievement); + xbox_free_achievement(&achievement); // Assert. TEST_ASSERT_NULL(achievement); @@ -688,7 +688,7 @@ static void free_achievement__one_achievement__null_achievement_returned(void) { static void free_achievement__two_achievements__null_achievement_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id"); achievement2->service_config_id = bstrdup("service-config-id"); achievement2->name = bstrdup("Achievement Name"); @@ -700,22 +700,22 @@ static void free_achievement__two_achievements__null_achievement_returned(void) achievement2->rewards = NULL; achievement2->next = NULL; - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id"); achievement1->service_config_id = bstrdup("service-config-id"); achievement1->name = bstrdup("Achievement Name"); @@ -728,7 +728,7 @@ static void free_achievement__two_achievements__null_achievement_returned(void) achievement1->next = achievement2; // Act. - free_achievement(&achievement1); + xbox_free_achievement(&achievement1); // Assert. TEST_ASSERT_NULL(achievement1); @@ -736,10 +736,10 @@ static void free_achievement__two_achievements__null_achievement_returned(void) static void copy_achievement__achievement_is_null__null_copy_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - const achievement_t *copy = copy_achievement(achievement); + const xbox_achievement_t *copy = xbox_copy_achievement(achievement); // Assert. TEST_ASSERT_NULL(copy); @@ -747,22 +747,22 @@ static void copy_achievement__achievement_is_null__null_copy_returned(void) { static void copy_achievement__one_achievement__copy_returned(void) { // Arrange. - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->service_config_id = bstrdup("service-config-id"); achievement->name = bstrdup("Achievement Name"); @@ -775,7 +775,7 @@ static void copy_achievement__one_achievement__copy_returned(void) { achievement->next = NULL; // Act. - const achievement_t *copy = copy_achievement(achievement); + const xbox_achievement_t *copy = xbox_copy_achievement(achievement); // Assert. TEST_ASSERT_NOT_NULL(achievement); @@ -795,7 +795,7 @@ static void copy_achievement__one_achievement__copy_returned(void) { static void copy_achievement__two_achievements__copy_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id"); achievement2->service_config_id = bstrdup("service-config-id"); achievement2->name = bstrdup("Achievement Name"); @@ -807,22 +807,22 @@ static void copy_achievement__two_achievements__copy_returned(void) { achievement2->rewards = NULL; achievement2->next = NULL; - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id"); achievement1->service_config_id = bstrdup("service-config-id"); achievement1->name = bstrdup("Achievement Name"); @@ -835,7 +835,7 @@ static void copy_achievement__two_achievements__copy_returned(void) { achievement1->next = achievement2; // Act. - const achievement_t *copy = copy_achievement(achievement1); + const xbox_achievement_t *copy = xbox_copy_achievement(achievement1); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -866,10 +866,10 @@ static void copy_achievement__two_achievements__copy_returned(void) { static void count_achievements__achievement_is_null__0_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - int total = count_achievements(achievement); + int total = xbox_count_achievements(achievement); // Assert. TEST_ASSERT_EQUAL_INT(total, 0); @@ -877,22 +877,22 @@ static void count_achievements__achievement_is_null__0_returned(void) { static void count_achievements__one_achievement__1_returned(void) { // Arrange. - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->service_config_id = bstrdup("service-config-id"); achievement->name = bstrdup("Achievement Name"); @@ -905,7 +905,7 @@ static void count_achievements__one_achievement__1_returned(void) { achievement->next = NULL; // Act. - int total = count_achievements(achievement); + int total = xbox_count_achievements(achievement); // Assert. TEST_ASSERT_EQUAL_INT(total, 1); @@ -913,7 +913,7 @@ static void count_achievements__one_achievement__1_returned(void) { static void count_achievements__two_achievements__2_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id"); achievement2->service_config_id = bstrdup("service-config-id"); achievement2->name = bstrdup("Achievement Name"); @@ -925,22 +925,22 @@ static void count_achievements__two_achievements__2_returned(void) { achievement2->rewards = NULL; achievement2->next = NULL; - reward_t *reward2 = bzalloc(sizeof(reward_t)); - reward2->value = bstrdup("1000"); - reward2->next = NULL; + xbox_reward_t *reward2 = bzalloc(sizeof(xbox_reward_t)); + reward2->value = bstrdup("1000"); + reward2->next = NULL; - reward_t *reward1 = bzalloc(sizeof(reward_t)); - reward1->value = bstrdup("1000"); - reward1->next = reward2; + xbox_reward_t *reward1 = bzalloc(sizeof(xbox_reward_t)); + reward1->value = bstrdup("1000"); + reward1->next = reward2; - media_asset_t *media_asset2 = bzalloc(sizeof(media_asset_t)); - media_asset2->url = bstrdup("https://www.example.com/image-1.png"); + xbox_media_asset_t *media_asset2 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset2->url = bstrdup("https://www.example.com/image-1.png"); - media_asset_t *media_asset1 = bzalloc(sizeof(media_asset_t)); - media_asset1->url = bstrdup("https://www.example.com/image-2.png"); - media_asset1->next = media_asset2; + xbox_media_asset_t *media_asset1 = bzalloc(sizeof(xbox_media_asset_t)); + media_asset1->url = bstrdup("https://www.example.com/image-2.png"); + media_asset1->next = media_asset2; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id"); achievement1->service_config_id = bstrdup("service-config-id"); achievement1->name = bstrdup("Achievement Name"); @@ -953,20 +953,20 @@ static void count_achievements__two_achievements__2_returned(void) { achievement1->next = achievement2; // Act. - int total = count_achievements(achievement1); + int total = xbox_count_achievements(achievement1); // Assert. TEST_ASSERT_EQUAL_INT(total, 2); } -// Tests count_locked_achievements +// Tests xbox_count_locked_achievements static void count_locked_achievements__achievement_is_null__0_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - int total = count_locked_achievements(achievement); + int total = xbox_count_locked_achievements(achievement); // Assert. TEST_ASSERT_EQUAL_INT(0, total); @@ -974,14 +974,14 @@ static void count_locked_achievements__achievement_is_null__0_returned(void) { static void count_locked_achievements__one_locked_achievement__1_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 0; // Locked achievement->next = NULL; // Act. - int total = count_locked_achievements(achievement); + int total = xbox_count_locked_achievements(achievement); // Assert. TEST_ASSERT_EQUAL_INT(1, total); @@ -989,14 +989,14 @@ static void count_locked_achievements__one_locked_achievement__1_returned(void) static void count_locked_achievements__one_unlocked_achievement__0_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 1234567890; // Unlocked achievement->next = NULL; // Act. - int total = count_locked_achievements(achievement); + int total = xbox_count_locked_achievements(achievement); // Assert. TEST_ASSERT_EQUAL_INT(0, total); @@ -1004,20 +1004,20 @@ static void count_locked_achievements__one_unlocked_achievement__0_returned(void static void count_locked_achievements__two_locked_achievements__2_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 0; // Locked achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 0; // Locked achievement1->next = achievement2; // Act. - int total = count_locked_achievements(achievement1); + int total = xbox_count_locked_achievements(achievement1); // Assert. TEST_ASSERT_EQUAL_INT(2, total); @@ -1025,20 +1025,20 @@ static void count_locked_achievements__two_locked_achievements__2_returned(void) static void count_locked_achievements__two_unlocked_achievements__0_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567890; // Unlocked achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 1234567800; // Unlocked achievement1->next = achievement2; // Act. - int total = count_locked_achievements(achievement1); + int total = xbox_count_locked_achievements(achievement1); // Assert. TEST_ASSERT_EQUAL_INT(0, total); @@ -1046,39 +1046,39 @@ static void count_locked_achievements__two_unlocked_achievements__0_returned(voi static void count_locked_achievements__mixed_achievements__locked_count_returned(void) { // Arrange. - achievement_t *achievement3 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement3 = bzalloc(sizeof(xbox_achievement_t)); achievement3->id = bstrdup("achievement-id-3"); achievement3->name = bstrdup("Achievement Name 3"); achievement3->unlocked_timestamp = 0; // Locked achievement3->next = NULL; - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567890; // Unlocked achievement2->next = achievement3; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 0; // Locked achievement1->next = achievement2; // Act. - int total = count_locked_achievements(achievement1); + int total = xbox_count_locked_achievements(achievement1); // Assert. TEST_ASSERT_EQUAL_INT(2, total); } -// Tests find_latest_unlocked_achievement +// Tests xbox_find_latest_unlocked_achievement static void find_latest_unlocked_achievement__achievement_is_null__null_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement); // Assert. TEST_ASSERT_NULL(result); @@ -1086,14 +1086,14 @@ static void find_latest_unlocked_achievement__achievement_is_null__null_returned static void find_latest_unlocked_achievement__one_locked_achievement__null_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 0; // Locked achievement->next = NULL; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement); // Assert. TEST_ASSERT_NULL(result); @@ -1101,14 +1101,14 @@ static void find_latest_unlocked_achievement__one_locked_achievement__null_retur static void find_latest_unlocked_achievement__one_unlocked_achievement__achievement_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 1234567890; // Unlocked achievement->next = NULL; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement); // Assert. TEST_ASSERT_NOT_NULL(result); @@ -1117,20 +1117,20 @@ static void find_latest_unlocked_achievement__one_unlocked_achievement__achievem static void find_latest_unlocked_achievement__two_unlocked_achievements__latest_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567900; // Unlocked later achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 1234567800; // Unlocked earlier achievement1->next = achievement2; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement1); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement1); // Assert. TEST_ASSERT_NOT_NULL(result); @@ -1139,20 +1139,20 @@ static void find_latest_unlocked_achievement__two_unlocked_achievements__latest_ static void find_latest_unlocked_achievement__latest_is_first_in_list__first_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567800; // Unlocked earlier achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 1234567900; // Unlocked later achievement1->next = achievement2; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement1); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement1); // Assert. TEST_ASSERT_NOT_NULL(result); @@ -1161,20 +1161,20 @@ static void find_latest_unlocked_achievement__latest_is_first_in_list__first_ret static void find_latest_unlocked_achievement__all_locked__null_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 0; // Locked achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 0; // Locked achievement1->next = achievement2; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement1); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement1); // Assert. TEST_ASSERT_NULL(result); @@ -1182,40 +1182,40 @@ static void find_latest_unlocked_achievement__all_locked__null_returned(void) { static void find_latest_unlocked_achievement__mixed_achievements__latest_unlocked_returned(void) { // Arrange. - achievement_t *achievement3 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement3 = bzalloc(sizeof(xbox_achievement_t)); achievement3->id = bstrdup("achievement-id-3"); achievement3->name = bstrdup("Achievement Name 3"); achievement3->unlocked_timestamp = 0; // Locked achievement3->next = NULL; - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567900; // Unlocked (latest) achievement2->next = achievement3; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 1234567800; // Unlocked (earlier) achievement1->next = achievement2; // Act. - const achievement_t *result = find_latest_unlocked_achievement(achievement1); + const xbox_achievement_t *result = xbox_find_latest_unlocked_achievement(achievement1); // Assert. TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("achievement-id-2", result->id); } -// Tests get_random_locked_achievement +// Tests xbox_get_random_locked_achievement static void get_random_locked_achievement__achievement_is_null__null_returned(void) { // Arrange. - achievement_t *achievement = NULL; + xbox_achievement_t *achievement = NULL; // Act. - const achievement_t *result = get_random_locked_achievement(achievement); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement); // Assert. TEST_ASSERT_NULL(result); @@ -1223,14 +1223,14 @@ static void get_random_locked_achievement__achievement_is_null__null_returned(vo static void get_random_locked_achievement__one_locked_achievement__achievement_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 0; // Locked achievement->next = NULL; // Act. - const achievement_t *result = get_random_locked_achievement(achievement); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement); // Assert. TEST_ASSERT_NOT_NULL(result); @@ -1239,14 +1239,14 @@ static void get_random_locked_achievement__one_locked_achievement__achievement_r static void get_random_locked_achievement__one_unlocked_achievement__null_returned(void) { // Arrange. - achievement_t *achievement = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement = bzalloc(sizeof(xbox_achievement_t)); achievement->id = bstrdup("achievement-id"); achievement->name = bstrdup("Achievement Name"); achievement->unlocked_timestamp = 1234567890; // Unlocked achievement->next = NULL; // Act. - const achievement_t *result = get_random_locked_achievement(achievement); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement); // Assert. TEST_ASSERT_NULL(result); @@ -1254,20 +1254,20 @@ static void get_random_locked_achievement__one_unlocked_achievement__null_return static void get_random_locked_achievement__all_unlocked__null_returned(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567900; // Unlocked achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 1234567800; // Unlocked achievement1->next = achievement2; // Act. - const achievement_t *result = get_random_locked_achievement(achievement1); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement1); // Assert. TEST_ASSERT_NULL(result); @@ -1275,26 +1275,26 @@ static void get_random_locked_achievement__all_unlocked__null_returned(void) { static void get_random_locked_achievement__mixed_achievements__locked_returned(void) { // Arrange. - achievement_t *achievement3 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement3 = bzalloc(sizeof(xbox_achievement_t)); achievement3->id = bstrdup("achievement-id-3"); achievement3->name = bstrdup("Achievement Name 3"); achievement3->unlocked_timestamp = 0; // Locked achievement3->next = NULL; - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("achievement-id-2"); achievement2->name = bstrdup("Achievement Name 2"); achievement2->unlocked_timestamp = 1234567890; // Unlocked achievement2->next = achievement3; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("achievement-id-1"); achievement1->name = bstrdup("Achievement Name 1"); achievement1->unlocked_timestamp = 0; // Locked achievement1->next = achievement2; // Act. - const achievement_t *result = get_random_locked_achievement(achievement1); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement1); // Assert. TEST_ASSERT_NOT_NULL(result); @@ -1305,13 +1305,13 @@ static void get_random_locked_achievement__mixed_achievements__locked_returned(v static void get_random_locked_achievement__multiple_calls__returns_locked_achievement(void) { // Arrange. - achievement_t *achievement2 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement2 = bzalloc(sizeof(xbox_achievement_t)); achievement2->id = bstrdup("locked-2"); achievement2->name = bstrdup("Locked Achievement 2"); achievement2->unlocked_timestamp = 0; // Locked achievement2->next = NULL; - achievement_t *achievement1 = bzalloc(sizeof(achievement_t)); + xbox_achievement_t *achievement1 = bzalloc(sizeof(xbox_achievement_t)); achievement1->id = bstrdup("locked-1"); achievement1->name = bstrdup("Locked Achievement 1"); achievement1->unlocked_timestamp = 0; // Locked @@ -1319,7 +1319,7 @@ static void get_random_locked_achievement__multiple_calls__returns_locked_achiev // Act & Assert - call multiple times, each should return a locked achievement for (int i = 0; i < 10; i++) { - const achievement_t *result = get_random_locked_achievement(achievement1); + const xbox_achievement_t *result = xbox_get_random_locked_achievement(achievement1); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_INT64(0, result->unlocked_timestamp); } @@ -1327,74 +1327,74 @@ static void get_random_locked_achievement__multiple_calls__returns_locked_achiev // Tests achievement_progress.c -static void free_achievement_progress__achievement_progress_is_null__null_achievement_progress_returned(void) { +static void xbox_free_achievement_progress__achievement_progress_is_null__null_achievement_progress_returned(void) { // Arrange. - achievement_progress_t *achievement_progress = NULL; + xbox_achievement_progress_t *achievement_progress = NULL; // Act. - free_achievement_progress(&achievement_progress); + xbox_free_achievement_progress(&achievement_progress); // Assert. TEST_ASSERT_NULL(achievement_progress); } -static void free_achievement_progress__one_achievement_progress__null_achievement_progress_returned(void) { +static void xbox_free_achievement_progress__one_achievement_progress__null_achievement_progress_returned(void) { // Arrange. - achievement_progress_t *achievement_progress = bzalloc(sizeof(achievement_progress_t)); - achievement_progress->id = bstrdup("achievement-progress-id"); - achievement_progress->service_config_id = bstrdup("service-config-id"); - achievement_progress->progress_state = bstrdup("unlocked"); - achievement_progress->next = NULL; + xbox_achievement_progress_t *achievement_progress = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress->id = bstrdup("achievement-progress-id"); + achievement_progress->service_config_id = bstrdup("service-config-id"); + achievement_progress->progress_state = bstrdup("unlocked"); + achievement_progress->next = NULL; // Act. - free_achievement_progress(&achievement_progress); + xbox_free_achievement_progress(&achievement_progress); // Assert. TEST_ASSERT_NULL(achievement_progress); } -static void free_achievement_progress__two_achievement_progresses__null_achievement_progress_returned(void) { +static void xbox_free_achievement_progress__two_achievement_progresses__null_achievement_progress_returned(void) { // Arrange. - achievement_progress_t *achievement_progress2 = bzalloc(sizeof(achievement_progress_t)); - achievement_progress2->id = bstrdup("achievement-progress-id-2"); - achievement_progress2->service_config_id = bstrdup("service-config-id"); - achievement_progress2->progress_state = bstrdup("unlocked"); - achievement_progress2->next = NULL; + xbox_achievement_progress_t *achievement_progress2 = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress2->id = bstrdup("achievement-progress-id-2"); + achievement_progress2->service_config_id = bstrdup("service-config-id"); + achievement_progress2->progress_state = bstrdup("unlocked"); + achievement_progress2->next = NULL; - achievement_progress_t *achievement_progress1 = bzalloc(sizeof(achievement_progress_t)); - achievement_progress1->id = bstrdup("achievement-progress-id-1"); - achievement_progress1->service_config_id = bstrdup("service-config-id"); - achievement_progress1->progress_state = bstrdup("unlocked"); - achievement_progress1->next = achievement_progress2; + xbox_achievement_progress_t *achievement_progress1 = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress1->id = bstrdup("achievement-progress-id-1"); + achievement_progress1->service_config_id = bstrdup("service-config-id"); + achievement_progress1->progress_state = bstrdup("unlocked"); + achievement_progress1->next = achievement_progress2; // Act. - free_achievement_progress(&achievement_progress1); + xbox_free_achievement_progress(&achievement_progress1); // Assert. TEST_ASSERT_NULL(achievement_progress1); } -static void copy_achievement_progress__achievement_progress_is_null__null_copy_returned(void) { +static void xbox_copy_achievement_progress__achievement_progress_is_null__null_copy_returned(void) { // Arrange. - achievement_progress_t *achievement_progress = NULL; + xbox_achievement_progress_t *achievement_progress = NULL; // Act. - const achievement_progress_t *copy = copy_achievement_progress(achievement_progress); + const xbox_achievement_progress_t *copy = xbox_copy_achievement_progress(achievement_progress); // Assert. TEST_ASSERT_NULL(copy); } -static void copy_achievement_progress__one_achievement_progress__copy_returned(void) { +static void xbox_copy_achievement_progress__one_achievement_progress__copy_returned(void) { // Arrange. - achievement_progress_t *achievement_progress = bzalloc(sizeof(achievement_progress_t)); - achievement_progress->id = bstrdup("achievement-progress-id"); - achievement_progress->service_config_id = bstrdup("service-config-id"); - achievement_progress->progress_state = bstrdup("unlocked"); - achievement_progress->next = NULL; + xbox_achievement_progress_t *achievement_progress = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress->id = bstrdup("achievement-progress-id"); + achievement_progress->service_config_id = bstrdup("service-config-id"); + achievement_progress->progress_state = bstrdup("unlocked"); + achievement_progress->next = NULL; // Act. - const achievement_progress_t *copy = copy_achievement_progress(achievement_progress); + const xbox_achievement_progress_t *copy = xbox_copy_achievement_progress(achievement_progress); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -1404,22 +1404,22 @@ static void copy_achievement_progress__one_achievement_progress__copy_returned(v TEST_ASSERT_NULL(copy->next); } -static void copy_achievement_progress__two_achievement_progresses__copy_returned(void) { +static void xbox_copy_achievement_progress__two_achievement_progresses__copy_returned(void) { // Arrange. - achievement_progress_t *achievement_progress2 = bzalloc(sizeof(achievement_progress_t)); - achievement_progress2->id = bstrdup("achievement-progress-id-2"); - achievement_progress2->service_config_id = bstrdup("service-config-id"); - achievement_progress2->progress_state = bstrdup("unlocked"); - achievement_progress2->next = NULL; + xbox_achievement_progress_t *achievement_progress2 = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress2->id = bstrdup("achievement-progress-id-2"); + achievement_progress2->service_config_id = bstrdup("service-config-id"); + achievement_progress2->progress_state = bstrdup("unlocked"); + achievement_progress2->next = NULL; - achievement_progress_t *achievement_progress1 = bzalloc(sizeof(achievement_progress_t)); - achievement_progress1->id = bstrdup("achievement-progress-id-1"); - achievement_progress1->service_config_id = bstrdup("service-config-id"); - achievement_progress1->progress_state = bstrdup("unlocked"); - achievement_progress1->next = achievement_progress2; + xbox_achievement_progress_t *achievement_progress1 = bzalloc(sizeof(xbox_achievement_progress_t)); + achievement_progress1->id = bstrdup("achievement-progress-id-1"); + achievement_progress1->service_config_id = bstrdup("service-config-id"); + achievement_progress1->progress_state = bstrdup("unlocked"); + achievement_progress1->next = achievement_progress2; // Act. - const achievement_progress_t *copy = copy_achievement_progress(achievement_progress1); + const xbox_achievement_progress_t *copy = xbox_copy_achievement_progress(achievement_progress1); // Assert. TEST_ASSERT_NOT_NULL(copy); @@ -1525,20 +1525,20 @@ int main(void) { RUN_TEST(copy_gamerscore__two_unlocked_achievements__total_returned); // Tests unlocked_achievement.c - RUN_TEST(free_unlocked_achievement__unlocked_achievement_is_null__null_unlocked_achievement_returned); - RUN_TEST(free_unlocked_achievement__unlocked_achievement_is_not_null__null_unlocked_achievement_returned); + RUN_TEST(xbox_free_unlocked_achievement__unlocked_achievement_is_null__null_unlocked_achievement_returned); + RUN_TEST(xbox_free_unlocked_achievement__unlocked_achievement_is_not_null__null_unlocked_achievement_returned); - RUN_TEST(copy_unlocked_achievement__unlocked_achievement_is_null__null_copy_returned); - RUN_TEST(copy_unlocked_achievement__unlocked_achievement_is_not_null__copy_returned); + RUN_TEST(xbox_copy_unlocked_achievement__unlocked_achievement_is_null__null_copy_returned); + RUN_TEST(xbox_copy_unlocked_achievement__unlocked_achievement_is_not_null__copy_returned); // Tests achievement_progress.c - RUN_TEST(free_achievement_progress__achievement_progress_is_null__null_achievement_progress_returned); - RUN_TEST(free_achievement_progress__one_achievement_progress__null_achievement_progress_returned); - RUN_TEST(free_achievement_progress__two_achievement_progresses__null_achievement_progress_returned); + RUN_TEST(xbox_free_achievement_progress__achievement_progress_is_null__null_achievement_progress_returned); + RUN_TEST(xbox_free_achievement_progress__one_achievement_progress__null_achievement_progress_returned); + RUN_TEST(xbox_free_achievement_progress__two_achievement_progresses__null_achievement_progress_returned); - RUN_TEST(copy_achievement_progress__achievement_progress_is_null__null_copy_returned); - RUN_TEST(copy_achievement_progress__one_achievement_progress__copy_returned); - RUN_TEST(copy_achievement_progress__two_achievement_progresses__copy_returned); + RUN_TEST(xbox_copy_achievement_progress__achievement_progress_is_null__null_copy_returned); + RUN_TEST(xbox_copy_achievement_progress__one_achievement_progress__copy_returned); + RUN_TEST(xbox_copy_achievement_progress__two_achievement_progresses__copy_returned); return UNITY_END(); } diff --git a/test/test_xbox_session.c b/test/test_xbox_session.c index 95895c8b..b4c2cab5 100644 --- a/test/test_xbox_session.c +++ b/test/test_xbox_session.c @@ -2,21 +2,21 @@ #include "stubs/xbox/xbox_client.h" #include "util/bmem.h" #include "common/types.h" -#include "xbox/xbox_session.h" +#include "integrations/xbox/xbox_session.h" #define OUTER_WORLD_2_ID "outer_worlds_2_id" #define FALLOUT_4_ID "fallout_4_id" -static game_t *game_outer_worlds_2; -static game_t *game_fallout_4; -static xbox_session_t *session; -static achievement_t *achievement_1; -static achievement_t *achievement_2; -static achievement_progress_t *achievement_progress_1; -static achievement_progress_t *achievement_progress_2; -static gamerscore_t *gamerscore; -static reward_t *reward_1; -static reward_t *reward_2; +static game_t *game_outer_worlds_2; +static game_t *game_fallout_4; +static xbox_session_t *session; +static xbox_achievement_t *achievement_1; +static xbox_achievement_t *achievement_2; +static xbox_achievement_progress_t *achievement_progress_1; +static xbox_achievement_progress_t *achievement_progress_2; +static gamerscore_t *gamerscore; +static xbox_reward_t *reward_1; +static xbox_reward_t *reward_2; void setUp(void) { @@ -28,11 +28,11 @@ void setUp(void) { session->gamerscore = copy_gamerscore(gamerscore); session->achievements = NULL; - reward_1 = bzalloc(sizeof(reward_t)); + reward_1 = bzalloc(sizeof(xbox_reward_t)); reward_1->value = bstrdup("80"); reward_1->next = NULL; - reward_2 = bzalloc(sizeof(reward_t)); + reward_2 = bzalloc(sizeof(xbox_reward_t)); reward_2->value = bstrdup("500"); reward_2->next = NULL; @@ -47,7 +47,7 @@ void setUp(void) { game_fallout_4->id = bstrdup(FALLOUT_4_ID); game_fallout_4->title = bstrdup("Fallout 4"); - achievement_2 = bzalloc(sizeof(achievement_t)); + achievement_2 = bzalloc(sizeof(xbox_achievement_t)); achievement_2->id = bstrdup("achievement-2"); achievement_2->service_config_id = NULL; achievement_2->name = NULL; @@ -55,11 +55,11 @@ void setUp(void) { achievement_2->locked_description = NULL; achievement_2->progress_state = NULL; achievement_2->description = NULL; - achievement_2->rewards = copy_reward(reward_2); + achievement_2->rewards = xbox_copy_reward(reward_2); achievement_2->media_assets = NULL; achievement_2->next = NULL; - achievement_1 = bzalloc(sizeof(achievement_t)); + achievement_1 = bzalloc(sizeof(xbox_achievement_t)); achievement_1->id = bstrdup("achievement-1"); achievement_1->service_config_id = NULL; achievement_1->name = NULL; @@ -67,17 +67,17 @@ void setUp(void) { achievement_1->locked_description = NULL; achievement_1->progress_state = NULL; achievement_1->description = NULL; - achievement_1->rewards = copy_reward(reward_1); + achievement_1->rewards = xbox_copy_reward(reward_1); achievement_1->media_assets = NULL; achievement_1->next = NULL; - achievement_progress_1 = bzalloc(sizeof(achievement_progress_t)); + achievement_progress_1 = bzalloc(sizeof(xbox_achievement_progress_t)); achievement_progress_1->id = bstrdup(achievement_1->id); achievement_progress_1->progress_state = bstrdup("Achieved"); achievement_progress_1->service_config_id = NULL; achievement_progress_1->next = NULL; - achievement_progress_2 = bzalloc(sizeof(achievement_progress_t)); + achievement_progress_2 = bzalloc(sizeof(xbox_achievement_progress_t)); achievement_progress_2->id = bstrdup(achievement_2->id); achievement_progress_2->progress_state = bstrdup("Achieved"); achievement_progress_2->service_config_id = NULL; @@ -92,14 +92,14 @@ void tearDown(void) { free_game(&game_outer_worlds_2); free_game(&game_fallout_4); - free_achievement(&achievement_2); - free_achievement(&achievement_1); + xbox_free_achievement(&achievement_2); + xbox_free_achievement(&achievement_1); - free_achievement_progress(&achievement_progress_1); - free_achievement_progress(&achievement_progress_2); + xbox_free_achievement_progress(&achievement_progress_1); + xbox_free_achievement_progress(&achievement_progress_2); - free_reward(&reward_1); - free_reward(&reward_2); + xbox_free_reward(&reward_1); + xbox_free_reward(&reward_2); free_gamerscore(&gamerscore); } @@ -195,7 +195,7 @@ static void xbox_session_change_game__session_has_no_game_and_game_is_null__no_g static void xbox_session_change_game__session_has_game_and_game_is_null__no_game_selected(void) { // Arrange. session->game = copy_game(game_outer_worlds_2); - session->achievements = copy_achievement(achievement_1); + session->achievements = xbox_copy_achievement(achievement_1); game_t *game = NULL; @@ -209,7 +209,7 @@ static void xbox_session_change_game__session_has_game_and_game_is_null__no_game static void xbox_session_change_game__session_has_no_game_and_game_is_not_null__game_selected(void) { // Arrange. - mock_xbox_client_set_achievements(copy_achievement(achievement_1)); + mock_xbox_client_set_achievements(xbox_copy_achievement(achievement_1)); game_t *game = copy_game(game_fallout_4); @@ -226,10 +226,10 @@ static void xbox_session_change_game__session_has_no_game_and_game_is_not_null__ static void xbox_session_change_game__session_has_game_and_game_is_not_null__new_game_selected(void) { // Arrange. - mock_xbox_client_set_achievements(copy_achievement(achievement_2)); + mock_xbox_client_set_achievements(xbox_copy_achievement(achievement_2)); session->game = copy_game(game_outer_worlds_2); - session->achievements = copy_achievement(achievement_1); + session->achievements = xbox_copy_achievement(achievement_1); game_t *game = copy_game(game_fallout_4); @@ -275,7 +275,7 @@ static void xbox_session_compute_gamerscore__session_has_one_unlocked_achievemen session->gamerscore = bzalloc(sizeof(gamerscore_t)); session->gamerscore->base_value = 1000; - session->gamerscore->unlocked_achievements = bzalloc(sizeof(unlocked_achievement_t)); + session->gamerscore->unlocked_achievements = bzalloc(sizeof(xbox_unlocked_achievement_t)); session->gamerscore->unlocked_achievements->value = 50; session->gamerscore->unlocked_achievements->next = NULL; @@ -291,10 +291,10 @@ static void xbox_session_compute_gamerscore__session_has_two_unlocked_achievemen session->gamerscore = bzalloc(sizeof(gamerscore_t)); session->gamerscore->base_value = 1000; - session->gamerscore->unlocked_achievements = bzalloc(sizeof(unlocked_achievement_t)); + session->gamerscore->unlocked_achievements = bzalloc(sizeof(xbox_unlocked_achievement_t)); session->gamerscore->unlocked_achievements->value = 50; - session->gamerscore->unlocked_achievements->next = bzalloc(sizeof(unlocked_achievement_t)); + session->gamerscore->unlocked_achievements->next = bzalloc(sizeof(xbox_unlocked_achievement_t)); session->gamerscore->unlocked_achievements->next->value = 80; session->gamerscore->unlocked_achievements->next->next = NULL; @@ -309,8 +309,8 @@ static void xbox_session_compute_gamerscore__session_has_two_unlocked_achievemen static void xbox_session_unlock_achievement__one_achievement_unlocked__gamerscore_incremented(void) { // Arrange. - achievement_t *achievements = copy_achievement(achievement_1); - achievements->next = copy_achievement(achievement_2); + xbox_achievement_t *achievements = xbox_copy_achievement(achievement_1); + achievements->next = xbox_copy_achievement(achievement_2); session->achievements = achievements; @@ -324,8 +324,8 @@ static void xbox_session_unlock_achievement__one_achievement_unlocked__gamerscor static void xbox_session_unlock_achievement__two_achievements_unlocked__gamerscore_incremented(void) { // Arrange. - achievement_t *achievements = copy_achievement(achievement_1); - achievements->next = copy_achievement(achievement_2); + xbox_achievement_t *achievements = xbox_copy_achievement(achievement_1); + achievements->next = xbox_copy_achievement(achievement_2); session->achievements = achievements; @@ -349,12 +349,12 @@ static void xbox_session_unlock_achievement__unknown_achievements_unlocked__game static void xbox_session_unlock_achievement__no_reward_found__gamerscore_unchanged(void) { // Arrange. - achievement_t *achievements = copy_achievement(achievement_1); - achievements->next = copy_achievement(achievement_2); + xbox_achievement_t *achievements = xbox_copy_achievement(achievement_1); + achievements->next = xbox_copy_achievement(achievement_2); session->achievements = achievements; - free_reward((reward_t **)&achievements->rewards); + xbox_free_reward(&achievements->rewards); // Act. xbox_session_unlock_achievement(session, achievement_progress_1);