From 0e6c9a9b887f2a6a555acf56698c9bf4a1054881 Mon Sep 17 00:00:00 2001 From: XeniOS Date: Wed, 3 Jun 2026 23:21:47 +0900 Subject: [PATCH] [iOS] Add XeniOS app layer --- .gitignore | 3 + CMakeLists.txt | 57 +- README.md | 102 +- cmake/XeniaHelpers.cmake | 22 +- src/xenia/CMakeLists.txt | 10 +- src/xenia/app/CMakeLists.txt | 166 + src/xenia/app/Info_ios.plist | 127 + src/xenia/app/directory_scanner.cc | 9 + src/xenia/app/xenia_main_ios.mm | 1544 ++++++++ src/xenia/apu/audio_system.cc | 63 +- src/xenia/apu/audio_system.h | 4 +- src/xenia/apu/conversion.h | 4 +- src/xenia/apu/sdl/sdl_audio_driver.cc | 75 +- src/xenia/apu/sdl/sdl_audio_driver.h | 9 + src/xenia/apu/sdl/sdl_audio_system.cc | 72 + src/xenia/base/CMakeLists.txt | 6 + src/xenia/base/autorelease_pool_mac.cc | 4 +- src/xenia/base/autorelease_pool_mac.h | 6 +- src/xenia/base/cache_control_ios.cc | 23 + src/xenia/base/exception_handler_posix.cc | 24 +- src/xenia/base/filesystem_posix.cc | 4 +- src/xenia/base/memory_posix.cc | 149 +- src/xenia/base/platform.h | 14 +- src/xenia/base/system_ios.cc | 36 + src/xenia/base/system_mac.cc | 14 + src/xenia/base/threading.h | 12 + src/xenia/base/threading_posix.cc | 103 +- src/xenia/cpu/backend/a64/a64_backend.cc | 44 +- src/xenia/cpu/backend/a64/a64_backend.h | 1 + .../cpu/backend/a64/a64_code_cache_posix.cc | 4 +- src/xenia/cpu/backend/a64/a64_emitter.cc | 179 +- src/xenia/cpu/backend/a64/a64_emitter.h | 7 + src/xenia/cpu/backend/code_cache_base.h | 670 +++- src/xenia/cpu/mmio_handler.cc | 2 +- src/xenia/cpu/ppc/testing/CMakeLists.txt | 1 + src/xenia/cpu/processor.cc | 4 + src/xenia/cpu/processor.h | 21 + src/xenia/cpu/testing/CMakeLists.txt | 3 + src/xenia/cpu/testing/test_backend_cvars.cc | 15 + src/xenia/cpu/thread_state.cc | 26 +- src/xenia/emulator.cc | 153 +- src/xenia/emulator.h | 7 + src/xenia/gpu/command_processor.cc | 10 +- src/xenia/gpu/command_processor.h | 5 + src/xenia/gpu/graphics_system.cc | 27 +- src/xenia/gpu/graphics_system.h | 21 +- src/xenia/gpu/null/CMakeLists.txt | 2 +- .../gpu/pm4_command_processor_implement.h | 32 + src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc | 30 +- src/xenia/helper/sdl/sdl_helper.cc | 13 + src/xenia/hid/touch/CMakeLists.txt | 45 + src/xenia/hid/touch/testing/CMakeLists.txt | 3 + .../touch/testing/touch_layout_ios_test.cc | 336 ++ src/xenia/hid/touch/touch_hid_ios.cc | 25 + src/xenia/hid/touch/touch_hid_ios.h | 28 + src/xenia/hid/touch/touch_input_driver_ios.cc | 346 ++ src/xenia/hid/touch/touch_input_driver_ios.h | 82 + src/xenia/hid/touch/touch_input_resolver.cc | 265 ++ src/xenia/hid/touch/touch_input_resolver.h | 76 + src/xenia/hid/touch/touch_layout_editor.cc | 237 ++ src/xenia/hid/touch/touch_layout_editor.h | 50 + src/xenia/hid/touch/touch_layout_ios.cc | 10 + src/xenia/hid/touch/touch_layout_ios.h | 297 ++ src/xenia/hid/touch/touch_layout_ios_codec.cc | 786 ++++ .../hid/touch/touch_layout_ios_defaults.cc | 123 + .../hid/touch/touch_layout_ios_geometry.cc | 64 + .../hid/touch/touch_layout_ios_internal.h | 31 + .../hid/touch/touch_layout_ios_labels.cc | 329 ++ .../hid/touch/touch_layout_ios_runtime.cc | 50 + src/xenia/kernel/kernel_state.cc | 223 +- src/xenia/kernel/kernel_state.h | 5 + src/xenia/kernel/xam/achievement_manager.cc | 60 +- src/xenia/kernel/xam/achievement_manager.h | 3 +- src/xenia/kernel/xam/user_data.h | 5 + src/xenia/kernel/xam/xam_content.cc | 14 + src/xenia/kernel/xam/xam_info.cc | 12 +- src/xenia/kernel/xam/xam_ui.cc | 367 +- .../kernel/xboxkrnl/xboxkrnl_threading.cc | 5 + src/xenia/kernel/xobject.cc | 145 +- src/xenia/kernel/xobject.h | 9 +- src/xenia/kernel/xthread.cc | 166 +- src/xenia/memory.cc | 18 +- src/xenia/memory.h | 2 +- src/xenia/patcher/patch_db.cc | 4 + src/xenia/patcher/patch_db.h | 1 + src/xenia/ui/CMakeLists.txt | 19 +- .../ui/achievement_notification_payload.h | 35 + src/xenia/ui/config_helpers.h | 2 +- src/xenia/ui/ios/CMakeLists.txt | 172 + src/xenia/ui/ios/app/ios_app_delegate.h | 35 + src/xenia/ui/ios/app/ios_app_delegate.mm | 353 ++ .../ios_controller_navigation_coordinator.h | 72 + .../ios_controller_navigation_coordinator.mm | 859 ++++ .../ios_display_settings_view_controller.h | 38 + .../ios_display_settings_view_controller.mm | 137 + .../ui/ios/app/ios_in_game_menu_overlay.h | 53 + .../ui/ios/app/ios_in_game_menu_overlay.mm | 467 +++ .../app/ios_landscape_navigation_controller.h | 22 + .../ios_landscape_navigation_controller.mm | 28 + .../ui/ios/app/ios_main_view_controller.h | 67 + .../ui/ios/app/ios_main_view_controller.mm | 3013 ++++++++++++++ .../app/ios_pause_dashboard_view_controller.h | 61 + .../ios_pause_dashboard_view_controller.mm | 308 ++ .../app/ios_pause_workspace_view_controller.h | 45 + .../ios_pause_workspace_view_controller.mm | 113 + src/xenia/ui/ios/app/ios_window_layout.h | 35 + src/xenia/ui/ios/app/ios_window_layout.mm | 121 + .../ui/ios/app/ios_window_position_overlay.h | 28 + .../ui/ios/app/ios_window_position_overlay.mm | 153 + .../ui/ios/app/windowed_app_context_ios.h | 441 +++ .../ui/ios/app/windowed_app_context_ios.mm | 159 + src/xenia/ui/ios/app/windowed_app_main_ios.mm | 23 + src/xenia/ui/ios/game/file_picker_ios.mm | 137 + .../ios_achievement_notification_presenter.h | 24 + .../ios_achievement_notification_presenter.mm | 511 +++ .../game/ios_achievements_view_controller.h | 38 + .../game/ios_achievements_view_controller.mm | 308 ++ src/xenia/ui/ios/game/ios_metal_view.h | 21 + src/xenia/ui/ios/game/ios_metal_view.mm | 28 + src/xenia/ui/ios/game/surface_ios.h | 49 + src/xenia/ui/ios/game/surface_ios.mm | 120 + src/xenia/ui/ios/game/window_ios.h | 84 + src/xenia/ui/ios/game/window_ios.mm | 374 ++ src/xenia/ui/ios/launcher/ios_compat_cache.h | 21 + src/xenia/ui/ios/launcher/ios_compat_cache.mm | 457 +++ src/xenia/ui/ios/launcher/ios_compat_data.h | 88 + src/xenia/ui/ios/launcher/ios_compat_data.mm | 19 + src/xenia/ui/ios/launcher/ios_compat_fetch.h | 15 + src/xenia/ui/ios/launcher/ios_compat_fetch.mm | 126 + .../ui/ios/launcher/ios_compat_formatting.h | 17 + .../ui/ios/launcher/ios_compat_formatting.mm | 463 +++ .../ui/ios/launcher/ios_compat_report_cells.h | 33 + .../ios/launcher/ios_compat_report_cells.mm | 224 ++ .../launcher/ios_compat_report_submission.h | 40 + .../launcher/ios_compat_report_submission.mm | 226 ++ .../ios_compat_report_view_controller.h | 29 + .../ios_compat_report_view_controller.mm | 551 +++ .../ui/ios/launcher/ios_compat_summary.h | 15 + .../ui/ios/launcher/ios_compat_summary.mm | 390 ++ .../ui/ios/launcher/ios_content_management.h | 77 + .../ui/ios/launcher/ios_content_management.mm | 193 + .../ios_document_import_coordinator.h | 51 + .../ios_document_import_coordinator.mm | 189 + src/xenia/ui/ios/launcher/ios_external_url.h | 38 + src/xenia/ui/ios/launcher/ios_external_url.mm | 333 ++ .../ios_game_actions_view_controller.h | 47 + .../ios_game_actions_view_controller.mm | 166 + src/xenia/ui/ios/launcher/ios_game_art.h | 33 + src/xenia/ui/ios/launcher/ios_game_art.mm | 204 + .../launcher/ios_game_compatibility_cells.h | 31 + .../launcher/ios_game_compatibility_cells.mm | 635 +++ ...game_compatibility_discussion_controller.h | 42 + ...ame_compatibility_discussion_controller.mm | 301 ++ .../ios_game_compatibility_hero_view.h | 42 + .../ios_game_compatibility_hero_view.mm | 806 ++++ .../ios_game_compatibility_view_controller.h | 30 + .../ios_game_compatibility_view_controller.mm | 317 ++ .../ios_game_content_view_controller.h | 38 + .../ios_game_content_view_controller.mm | 305 ++ .../launcher/ios_game_disc_view_controller.h | 28 + .../launcher/ios_game_disc_view_controller.mm | 151 + src/xenia/ui/ios/launcher/ios_game_library.h | 69 + src/xenia/ui/ios/launcher/ios_game_library.mm | 369 ++ .../ui/ios/launcher/ios_game_library_store.h | 35 + .../ui/ios/launcher/ios_game_library_store.mm | 243 ++ .../ios_game_patches_view_controller.h | 33 + .../ios_game_patches_view_controller.mm | 228 ++ .../ios_game_picker_view_controller.h | 47 + .../ios_game_picker_view_controller.mm | 126 + .../ui/ios/launcher/ios_game_tile_cell.h | 29 + .../ui/ios/launcher/ios_game_tile_cell.mm | 238 ++ .../ios/launcher/ios_launcher_overlay_view.h | 89 + .../ios/launcher/ios_launcher_overlay_view.mm | 1061 +++++ .../ios_choice_list_view_controller.h | 34 + .../ios_choice_list_view_controller.mm | 115 + .../ui/ios/settings/ios_config_builder.h | 23 + .../ui/ios/settings/ios_config_builder.mm | 10 + .../ui/ios/settings/ios_config_catalog.h | 26 + .../ui/ios/settings/ios_config_catalog.mm | 949 +++++ src/xenia/ui/ios/settings/ios_config_models.h | 99 + .../ui/ios/settings/ios_config_storage.h | 50 + .../ui/ios/settings/ios_config_storage.mm | 624 +++ .../ios/settings/ios_config_view_controller.h | 45 + .../settings/ios_config_view_controller.mm | 1103 ++++++ .../ios_debug_settings_view_controller.h | 27 + .../ios_debug_settings_view_controller.mm | 128 + .../ui/ios/settings/ios_log_view_controller.h | 25 + .../ios/settings/ios_log_view_controller.mm | 367 ++ .../settings/ios_profile_view_controller.h | 30 + .../settings/ios_profile_view_controller.mm | 217 ++ .../ios_quick_settings_view_controller.h | 23 + .../ios_quick_settings_view_controller.mm | 59 + .../ios_settings_hub_view_controller.h | 79 + .../ios_settings_hub_view_controller.mm | 1350 +++++++ src/xenia/ui/ios/shared/apple_theme_tokens.cc | 184 + src/xenia/ui/ios/shared/apple_theme_tokens.h | 175 + .../ui/ios/shared/apple_ui_navigation.cc | 265 ++ src/xenia/ui/ios/shared/apple_ui_navigation.h | 139 + .../ui/ios/shared/ios_hero_glow_palette.h | 28 + .../ui/ios/shared/ios_hero_glow_palette.mm | 347 ++ src/xenia/ui/ios/shared/ios_status_toast.h | 31 + src/xenia/ui/ios/shared/ios_status_toast.mm | 170 + src/xenia/ui/ios/shared/ios_system_utils.h | 61 + src/xenia/ui/ios/shared/ios_system_utils.mm | 365 ++ src/xenia/ui/ios/shared/ios_theme.h | 139 + src/xenia/ui/ios/shared/ios_theme.mm | 271 ++ src/xenia/ui/ios/shared/ios_theme_controls.h | 42 + src/xenia/ui/ios/shared/ios_theme_controls.mm | 200 + src/xenia/ui/ios/shared/ios_view_helpers.h | 131 + src/xenia/ui/ios/shared/ios_view_helpers.mm | 323 ++ .../ios/touch/touch_control_shell_view_ios.h | 30 + .../ios/touch/touch_control_shell_view_ios.mm | 285 ++ .../touch_controls_overlay_helpers_ios.h | 108 + .../touch_controls_overlay_helpers_ios.mm | 328 ++ .../ui/ios/touch/touch_controls_overlay_ios.h | 58 + .../ios/touch/touch_controls_overlay_ios.mm | 3469 +++++++++++++++++ .../touch_layout_editor_view_controller_ios.h | 31 + ...touch_layout_editor_view_controller_ios.mm | 287 ++ .../touch_layout_library_controller_ios.h | 33 + .../touch_layout_library_controller_ios.mm | 312 ++ .../ui/ios/touch/touch_layout_library_ios.h | 60 + .../ui/ios/touch/touch_layout_library_ios.mm | 292 ++ .../ios/touch/touch_layout_library_view_ios.h | 40 + .../touch/touch_layout_library_view_ios.mm | 226 ++ .../ui/ios/touch/touch_layout_store_ios.h | 41 + .../ui/ios/touch/touch_layout_store_ios.mm | 316 ++ .../touch/touch_layout_ui_coordinator_ios.h | 83 + .../touch/touch_layout_ui_coordinator_ios.mm | 1067 +++++ .../ios/touch/touch_overlay_edit_chrome_ios.h | 117 + .../touch/touch_overlay_edit_chrome_ios.mm | 1185 ++++++ .../touch/touch_overlay_edit_history_ios.h | 58 + .../touch/touch_overlay_edit_history_ios.mm | 187 + .../ui/ios/touch/touch_overlay_geometry_ios.h | 99 + .../ios/touch/touch_overlay_geometry_ios.mm | 503 +++ .../ui/ios/touch/touch_overlay_style_ios.h | 39 + .../ui/ios/touch/touch_overlay_style_ios.mm | 107 + src/xenia/ui/metal/CMakeLists.txt | 12 +- src/xenia/ui/metal/metal_presenter.mm | 33 +- src/xenia/ui/surface.h | 3 + src/xenia/ui/vulkan/vulkan_instance.cc | 8 +- src/xenia/ui/vulkan/vulkan_instance.h | 2 +- src/xenia/ui/vulkan/vulkan_presenter.cc | 5 + src/xenia/vfs/devices/stfs_xbox.h | 177 +- .../vfs/devices/xcontent_container_device.cc | 5 +- .../vfs/devices/xcontent_container_device.h | 4 +- .../xcontent_devices/stfs_container_device.cc | 2 +- .../xcontent_devices/svod_container_device.cc | 11 +- src/xenia/vfs/stfs_metadata.cc | 8 +- src/xenia/vfs/stfs_metadata.h | 1 + third_party/CMakeLists.txt | 83 +- tools/build/shader_cc.cc | 9 +- tools/fetch_moltenvk.py | 169 + tools/package_ios_ipa.sh | 78 + xenia-appRelease.entitlements | 12 + xenia-build.py | 105 +- xenia_ios.entitlements | 10 + 256 files changed, 43958 insertions(+), 446 deletions(-) create mode 100644 src/xenia/app/Info_ios.plist create mode 100644 src/xenia/app/xenia_main_ios.mm create mode 100644 src/xenia/base/cache_control_ios.cc create mode 100644 src/xenia/base/system_ios.cc create mode 100644 src/xenia/cpu/testing/test_backend_cvars.cc create mode 100644 src/xenia/hid/touch/CMakeLists.txt create mode 100644 src/xenia/hid/touch/testing/CMakeLists.txt create mode 100644 src/xenia/hid/touch/testing/touch_layout_ios_test.cc create mode 100644 src/xenia/hid/touch/touch_hid_ios.cc create mode 100644 src/xenia/hid/touch/touch_hid_ios.h create mode 100644 src/xenia/hid/touch/touch_input_driver_ios.cc create mode 100644 src/xenia/hid/touch/touch_input_driver_ios.h create mode 100644 src/xenia/hid/touch/touch_input_resolver.cc create mode 100644 src/xenia/hid/touch/touch_input_resolver.h create mode 100644 src/xenia/hid/touch/touch_layout_editor.cc create mode 100644 src/xenia/hid/touch/touch_layout_editor.h create mode 100644 src/xenia/hid/touch/touch_layout_ios.cc create mode 100644 src/xenia/hid/touch/touch_layout_ios.h create mode 100644 src/xenia/hid/touch/touch_layout_ios_codec.cc create mode 100644 src/xenia/hid/touch/touch_layout_ios_defaults.cc create mode 100644 src/xenia/hid/touch/touch_layout_ios_geometry.cc create mode 100644 src/xenia/hid/touch/touch_layout_ios_internal.h create mode 100644 src/xenia/hid/touch/touch_layout_ios_labels.cc create mode 100644 src/xenia/hid/touch/touch_layout_ios_runtime.cc create mode 100644 src/xenia/ui/achievement_notification_payload.h create mode 100644 src/xenia/ui/ios/CMakeLists.txt create mode 100644 src/xenia/ui/ios/app/ios_app_delegate.h create mode 100644 src/xenia/ui/ios/app/ios_app_delegate.mm create mode 100644 src/xenia/ui/ios/app/ios_controller_navigation_coordinator.h create mode 100644 src/xenia/ui/ios/app/ios_controller_navigation_coordinator.mm create mode 100644 src/xenia/ui/ios/app/ios_display_settings_view_controller.h create mode 100644 src/xenia/ui/ios/app/ios_display_settings_view_controller.mm create mode 100644 src/xenia/ui/ios/app/ios_in_game_menu_overlay.h create mode 100644 src/xenia/ui/ios/app/ios_in_game_menu_overlay.mm create mode 100644 src/xenia/ui/ios/app/ios_landscape_navigation_controller.h create mode 100644 src/xenia/ui/ios/app/ios_landscape_navigation_controller.mm create mode 100644 src/xenia/ui/ios/app/ios_main_view_controller.h create mode 100644 src/xenia/ui/ios/app/ios_main_view_controller.mm create mode 100644 src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.h create mode 100644 src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.mm create mode 100644 src/xenia/ui/ios/app/ios_pause_workspace_view_controller.h create mode 100644 src/xenia/ui/ios/app/ios_pause_workspace_view_controller.mm create mode 100644 src/xenia/ui/ios/app/ios_window_layout.h create mode 100644 src/xenia/ui/ios/app/ios_window_layout.mm create mode 100644 src/xenia/ui/ios/app/ios_window_position_overlay.h create mode 100644 src/xenia/ui/ios/app/ios_window_position_overlay.mm create mode 100644 src/xenia/ui/ios/app/windowed_app_context_ios.h create mode 100644 src/xenia/ui/ios/app/windowed_app_context_ios.mm create mode 100644 src/xenia/ui/ios/app/windowed_app_main_ios.mm create mode 100644 src/xenia/ui/ios/game/file_picker_ios.mm create mode 100644 src/xenia/ui/ios/game/ios_achievement_notification_presenter.h create mode 100644 src/xenia/ui/ios/game/ios_achievement_notification_presenter.mm create mode 100644 src/xenia/ui/ios/game/ios_achievements_view_controller.h create mode 100644 src/xenia/ui/ios/game/ios_achievements_view_controller.mm create mode 100644 src/xenia/ui/ios/game/ios_metal_view.h create mode 100644 src/xenia/ui/ios/game/ios_metal_view.mm create mode 100644 src/xenia/ui/ios/game/surface_ios.h create mode 100644 src/xenia/ui/ios/game/surface_ios.mm create mode 100644 src/xenia/ui/ios/game/window_ios.h create mode 100644 src/xenia/ui/ios/game/window_ios.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_cache.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_cache.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_data.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_data.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_fetch.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_fetch.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_formatting.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_formatting.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_cells.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_cells.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_submission.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_submission.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_compat_summary.h create mode 100644 src/xenia/ui/ios/launcher/ios_compat_summary.mm create mode 100644 src/xenia/ui/ios/launcher/ios_content_management.h create mode 100644 src/xenia/ui/ios/launcher/ios_content_management.mm create mode 100644 src/xenia/ui/ios/launcher/ios_document_import_coordinator.h create mode 100644 src/xenia/ui/ios/launcher/ios_document_import_coordinator.mm create mode 100644 src/xenia/ui/ios/launcher/ios_external_url.h create mode 100644 src/xenia/ui/ios/launcher/ios_external_url.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_actions_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_actions_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_art.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_art.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_cells.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_cells.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_content_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_content_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_disc_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_disc_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_library.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_library.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_library_store.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_library_store.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_patches_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_patches_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_picker_view_controller.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_picker_view_controller.mm create mode 100644 src/xenia/ui/ios/launcher/ios_game_tile_cell.h create mode 100644 src/xenia/ui/ios/launcher/ios_game_tile_cell.mm create mode 100644 src/xenia/ui/ios/launcher/ios_launcher_overlay_view.h create mode 100644 src/xenia/ui/ios/launcher/ios_launcher_overlay_view.mm create mode 100644 src/xenia/ui/ios/settings/ios_choice_list_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_choice_list_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_config_builder.h create mode 100644 src/xenia/ui/ios/settings/ios_config_builder.mm create mode 100644 src/xenia/ui/ios/settings/ios_config_catalog.h create mode 100644 src/xenia/ui/ios/settings/ios_config_catalog.mm create mode 100644 src/xenia/ui/ios/settings/ios_config_models.h create mode 100644 src/xenia/ui/ios/settings/ios_config_storage.h create mode 100644 src/xenia/ui/ios/settings/ios_config_storage.mm create mode 100644 src/xenia/ui/ios/settings/ios_config_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_config_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_debug_settings_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_debug_settings_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_log_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_log_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_profile_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_profile_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_quick_settings_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_quick_settings_view_controller.mm create mode 100644 src/xenia/ui/ios/settings/ios_settings_hub_view_controller.h create mode 100644 src/xenia/ui/ios/settings/ios_settings_hub_view_controller.mm create mode 100644 src/xenia/ui/ios/shared/apple_theme_tokens.cc create mode 100644 src/xenia/ui/ios/shared/apple_theme_tokens.h create mode 100644 src/xenia/ui/ios/shared/apple_ui_navigation.cc create mode 100644 src/xenia/ui/ios/shared/apple_ui_navigation.h create mode 100644 src/xenia/ui/ios/shared/ios_hero_glow_palette.h create mode 100644 src/xenia/ui/ios/shared/ios_hero_glow_palette.mm create mode 100644 src/xenia/ui/ios/shared/ios_status_toast.h create mode 100644 src/xenia/ui/ios/shared/ios_status_toast.mm create mode 100644 src/xenia/ui/ios/shared/ios_system_utils.h create mode 100644 src/xenia/ui/ios/shared/ios_system_utils.mm create mode 100644 src/xenia/ui/ios/shared/ios_theme.h create mode 100644 src/xenia/ui/ios/shared/ios_theme.mm create mode 100644 src/xenia/ui/ios/shared/ios_theme_controls.h create mode 100644 src/xenia/ui/ios/shared/ios_theme_controls.mm create mode 100644 src/xenia/ui/ios/shared/ios_view_helpers.h create mode 100644 src/xenia/ui/ios/shared/ios_view_helpers.mm create mode 100644 src/xenia/ui/ios/touch/touch_control_shell_view_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_control_shell_view_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_controls_overlay_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_controls_overlay_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_controller_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_controller_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_view_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_library_view_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_store_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_store_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_overlay_geometry_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_overlay_geometry_ios.mm create mode 100644 src/xenia/ui/ios/touch/touch_overlay_style_ios.h create mode 100644 src/xenia/ui/ios/touch/touch_overlay_style_ios.mm create mode 100755 tools/fetch_moltenvk.py create mode 100644 tools/package_ios_ipa.sh create mode 100644 xenia-appRelease.entitlements create mode 100644 xenia_ios.entitlements diff --git a/.gitignore b/.gitignore index b4d0565e2..899462f55 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ node_modules/ /build-vs/ /build-arm64/ /build-x64/ +/build-ios/ +/build-ios-xcode/ # CMake user-specific overrides + compile commands export CMakeUserPresets.json @@ -105,6 +107,7 @@ CMakeUserPresets.json /third_party/binutils/bin/ /third_party/binutils/powerpc-none-elf/ /third_party/binutils/share/ +/third_party/MoltenVK/ /third_party/vasm/ /tools/shader-playground/*.dll /profile_print_times.py diff --git a/CMakeLists.txt b/CMakeLists.txt index fae50b345..089b4c90c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,15 +1,30 @@ cmake_minimum_required(VERSION 3.20) set(CMAKE_TRY_COMPILE_CONFIGURATION Release) -# Must be set before project() so the toolchain bakes -mmacosx-version-min -# into every compile/link; setting it later only updates the variable. -if(APPLE) +# Keep Apple platform selection explicit. CMake sets APPLE for both macOS and +# iOS, but the two targets need different bundles, frameworks and dependencies. +if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(XE_PLATFORM_IOS TRUE) + set(XE_PLATFORM_MACOS FALSE) +elseif(APPLE) + set(XE_PLATFORM_IOS FALSE) + set(XE_PLATFORM_MACOS TRUE) +else() + set(XE_PLATFORM_IOS FALSE) + set(XE_PLATFORM_MACOS FALSE) +endif() + +# Must be set before project() so the toolchain bakes the deployment target into +# every compile/link; setting it later only updates the variable. +if(XE_PLATFORM_IOS) + set(CMAKE_OSX_DEPLOYMENT_TARGET "18.0" CACHE STRING "Minimum iOS version") +elseif(XE_PLATFORM_MACOS) set(CMAKE_OSX_DEPLOYMENT_TARGET "15.0" CACHE STRING "Minimum macOS version") endif() # Suppress macOS ranlib warnings for empty object files. wxWidgets's vendored # libpng compiles Intel/MIPS/PowerPC sources that are empty on ARM64. -if(APPLE) +if(APPLE OR XE_PLATFORM_IOS) set(CMAKE_C_ARCHIVE_FINISH " -no_warning_for_no_symbols ") set(CMAKE_CXX_ARCHIVE_FINISH " -no_warning_for_no_symbols ") set(CMAKE_OBJC_ARCHIVE_FINISH " -no_warning_for_no_symbols ") @@ -35,8 +50,8 @@ set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # Detect target architecture. # - VS generator: CMAKE_GENERATOR_PLATFORM (from -A flag) takes priority. -# - macOS: CMAKE_OSX_ARCHITECTURES — single-arch only (universal builds -# would need both backends compiled, which we don't support). +# - Apple: CMAKE_OSX_ARCHITECTURES — single-arch only (universal builds would +# need both backends compiled, which we don't support). # - Ninja/Makefiles: CMAKE_SYSTEM_PROCESSOR (set via -DCMAKE_SYSTEM_NAME # + -DCMAKE_SYSTEM_PROCESSOR for cross-compile, or auto-detected natively). if(CMAKE_GENERATOR_PLATFORM) @@ -82,6 +97,7 @@ option(XENIA_ENABLE_PROFILER "Enable profiler (UI is Debug-only)" OFF) option(XENIA_ENABLE_ITRACE "Enable JIT per-instruction tracing to the log (slow)" OFF) option(XENIA_ENABLE_DTRACE "Enable JIT per-operation data tracing to the log (very slow)" OFF) option(XENIA_ENABLE_FTRACE "Enable JIT per-function-call tracing to the log" OFF) +option(XENIA_ENABLE_IOS_MOLTENVK "Enable the optional MoltenVK backend in iOS builds" OFF) if(XENIA_ENABLE_PROFILER) # UI is Debug-only — shutdown dump spams the log otherwise. @@ -113,7 +129,9 @@ set(CMAKE_STATIC_LINKER_FLAGS_CHECKED "${CMAKE_STATIC_LINKER_FLAGS_DEBUG}" CACHE if(WIN32) set(XE_PLATFORM_NAME "Windows") -elseif(APPLE) +elseif(XE_PLATFORM_IOS) + set(XE_PLATFORM_NAME "iOS") +elseif(XE_PLATFORM_MACOS) set(XE_PLATFORM_NAME "macOS") else() set(XE_PLATFORM_NAME "Linux") @@ -327,7 +345,7 @@ else() ) if(APPLE) - # macOS-specific settings + # Apple-specific settings. add_compile_options( -Wno-unknown-warning-option $<$:-Wno-shorten-64-to-32> @@ -336,20 +354,27 @@ else() -fno-common ) add_link_options(-Wl,-no_warn_duplicate_libraries) - if(XE_TARGET_X86_64) + if(XE_PLATFORM_MACOS AND XE_TARGET_X86_64) # macOS 64-bit binaries default to a 4GB __PAGEZERO, which makes every # address below 4GB unmapped. The x64 backend places its constant # table (PlaceConstData) sub-2GB and its guest trampolines sub-4GB, # so we shrink __PAGEZERO to one page to free up that range. add_link_options(-Wl,-pagezero_size,0x1000) endif() - link_libraries( - "-framework CoreFoundation" - "-framework Foundation" - "-framework IOKit" - dl - pthread - ) + if(XE_PLATFORM_MACOS) + link_libraries( + "-framework CoreFoundation" + "-framework Foundation" + "-framework IOKit" + dl + pthread + ) + elseif(XE_PLATFORM_IOS) + link_libraries( + "-framework CoreFoundation" + "-framework Foundation" + ) + endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") find_package(PkgConfig REQUIRED) pkg_check_modules(GTK3 REQUIRED gtk+-x11-3.0) diff --git a/README.md b/README.md index 2bec0b121..2118b8d7b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,100 @@

- - - + XeniOS app icon

-

Xenia Edge - Xbox 360 Emulator

+

XeniOS - Xbox 360 Emulator

-Xenia Edge is yet another experimental fork of the Xenia emulator, originally based on [Xenia Canary](https://github.com/xenia-canary/xenia-canary). The focus is -on faster iteration, higher default game compatibility, usability and platform support. +XeniOS is an experimental Apple-focused fork of Xenia, currently based on +[Xenia Edge](https://github.com/has207/xenia-edge). It exists as a fast-moving +place to develop, test, and ship iOS and macOS work while also carrying +platform changes that benefit ARM64 Windows, Linux, and Android. Relevant +improvements are intended to flow back upstream over time. -## Status +

+ Website ◦ + Releases ◦ + Docs ◦ + FAQ ◦ + Compatibility ◦ + Discord ◦ + Issues +

-Build (Windows / Linux / macOS arm64 / macOS x86_64): [![CI](https://github.com/has207/xenia-edge/actions/workflows/CI.yml/badge.svg?branch=edge)](https://github.com/has207/xenia-edge/actions/workflows/CI.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/cd506034fd8148309a45034925648499)](https://app.codacy.com/gh/has207/xenia-edge/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +## Current Focus -Releases --------- -[Latest](https://github.com/has207/xenia-edge/releases/latest) ◦ [All](https://github.com/has207/xenia-edge/releases) +- iOS +- macOS (Apple Silicon) +- macOS (Intel) +Current published releases focus on iOS and macOS. +For Windows or Linux builds, use [Xenia Edge](https://github.com/has207/xenia-edge) or [Xenia Canary](https://github.com/xenia-canary/xenia-canary). +## Why This Fork Exists + +Xenia development is relatively thinly staffed right now, and upstream is not +set up for fast iteration on Apple-specific packaging, documentation, release +flow, and user experience. XeniOS exists so that work can move faster in a +repository where the full product experience can be shaped directly, from the +app itself to releases, docs, compatibility reporting, and the public website +at [xenios.jp](https://xenios.jp). + +The goal is not to keep good work siloed here forever. The goal is to iterate +quickly, build a more polished user-facing experience for Apple platforms, and +then contribute the useful technical improvements back upstream into the +broader Xenia community, especially +[Xenia Canary](https://github.com/xenia-canary/xenia-canary). + +## Downloads + +Download XeniOS from +[GitHub Releases](https://github.com/xenios-jp/XeniOS/releases). + +- [Latest GitHub release](https://github.com/xenios-jp/XeniOS/releases/latest) +- [All GitHub releases](https://github.com/xenios-jp/XeniOS/releases) + +## Quickstart + +Start with the public docs at [xenios.jp/docs](https://xenios.jp/docs). + +## FAQ + +See the public FAQ at [xenios.jp/faq](https://xenios.jp/faq). + +## Game Compatibility + +Browse currently tracked games on +[xenios.jp/compatibility](https://xenios.jp/compatibility). + +To file a compatibility report, use the +[GitHub compatibility tracker](https://github.com/xenios-jp/game-compatibility/issues/new/choose). + +## Building + +See [building.md](docs/building.md) for setup and information about the +`xb` script. When writing code, check the [style guide](docs/style_guide.md) +and be sure to run clang-format! + +## Contributors Wanted! + +Have some spare time, know advanced C++, and want to write an emulator? +Contribute! There's a ton of work that needs to be done, a lot of which +is wide open greenfield fun. + +**For general rules and guidelines please see [CONTRIBUTING.md](.github/CONTRIBUTING.md).** + +Fixes and optimizations are always welcome, especially around Apple platform +performance, UI polish, compatibility coverage, packaging, and tooling. + +Start with the +[XeniOS issue tracker](https://github.com/xenios-jp/XeniOS/issues), +join the [XeniOS Discord](https://discord.gg/QwcTtNKTGf), check +[CONTRIBUTING.md](.github/CONTRIBUTING.md), and coordinate before starting +larger work. + +## Disclaimer + +The goal of this project is to experiment, research, and educate on the topic +of emulation of modern devices and operating systems. **It is not for enabling +illegal activity**. All information is obtained via reverse engineering of +legally purchased devices and games and information made public on the internet +(you'd be surprised what's indexed on Google...). diff --git a/cmake/XeniaHelpers.cmake b/cmake/XeniaHelpers.cmake index 443769e24..cb9ac7c0e 100644 --- a/cmake/XeniaHelpers.cmake +++ b/cmake/XeniaHelpers.cmake @@ -3,7 +3,7 @@ include(CMakeParseArguments) set(XE_PLATFORM_SUFFIXES - _win _linux _posix _gnulinux _x11 _gtk _android _mac _amd64 _x64 _arm64 + _win _linux _posix _gnulinux _x11 _gtk _android _mac _ios _amd64 _x64 _arm64 ) # xe_platform_sources(target base_path [RECURSIVE]) @@ -55,7 +55,14 @@ function(xe_platform_sources target base_path) "${base_path}/*_win.h" "${base_path}/*_win.cc" ) - elseif(APPLE) + elseif(XE_PLATFORM_IOS) + file(${glob_mode} _plat_sources + "${base_path}/*_posix.h" + "${base_path}/*_posix.cc" + "${base_path}/*_ios.h" + "${base_path}/*_ios.cc" + ) + elseif(XE_PLATFORM_MACOS) file(${glob_mode} _plat_sources "${base_path}/*_posix.h" "${base_path}/*_posix.cc" @@ -106,6 +113,8 @@ function(xe_target_defaults target) ) if(MSVC) target_compile_options(${target} PRIVATE /WX) + elseif(XCODE AND XE_PLATFORM_IOS) + target_compile_options(${target} PRIVATE -w) elseif(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") target_compile_options(${target} PRIVATE -Werror) endif() @@ -327,6 +336,13 @@ function(xe_shader_rules_metal target shader_dir) set(_generated_root "${PROJECT_BINARY_DIR}/generated") set(_bytecode_dir "${_generated_root}/${_rel_dir}/bytecode/metal") set(_valid_stages vs ps cs) + set(_metal_args --msl) + if(XE_PLATFORM_IOS) + list(APPEND _metal_args + --metal-sdk iphoneos + --metal-std ios-metal2.3 + --metal-min-version-flag "-miphoneos-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() set(_outputs) file(MAKE_DIRECTORY "${_bytecode_dir}") foreach(src ${_sources}) @@ -350,7 +366,7 @@ function(xe_shader_rules_metal target shader_dir) list(APPEND _outputs "${_out}") add_custom_command( OUTPUT "${_out}" - COMMAND $ --msl --depfile "${_dep}" + COMMAND $ ${_metal_args} --depfile "${_dep}" "${src}" "${_out}" DEPENDS "${src}" xenia-shader-cc DEPFILE "${_dep}" diff --git a/src/xenia/CMakeLists.txt b/src/xenia/CMakeLists.txt index c95cc310e..30ec2b441 100644 --- a/src/xenia/CMakeLists.txt +++ b/src/xenia/CMakeLists.txt @@ -28,19 +28,25 @@ if(APPLE) endif() add_subdirectory(helper/sdl) add_subdirectory(hid) +add_subdirectory(hid/touch) add_subdirectory(hid/nop) add_subdirectory(hid/sdl) add_subdirectory(hid/portal) add_subdirectory(kernel) add_subdirectory(patcher) add_subdirectory(ui) +if(XE_PLATFORM_IOS) + add_subdirectory(ui/ios) +endif() add_subdirectory(ui/vulkan) if(APPLE) add_subdirectory(ui/metal) endif() add_subdirectory(vfs) -add_subdirectory(debug/ui) -add_subdirectory(app/discord) +if(NOT XE_PLATFORM_IOS) + add_subdirectory(debug/ui) + add_subdirectory(app/discord) +endif() add_subdirectory(app) if(WIN32) diff --git a/src/xenia/app/CMakeLists.txt b/src/xenia/app/CMakeLists.txt index 608e59de7..01717e56a 100644 --- a/src/xenia/app/CMakeLists.txt +++ b/src/xenia/app/CMakeLists.txt @@ -1,3 +1,169 @@ +if(XE_PLATFORM_IOS) + set(_xe_ios_entitlements "${PROJECT_SOURCE_DIR}/xenia_ios.entitlements") + add_executable(xenia-app MACOSX_BUNDLE + ${CMAKE_CURRENT_SOURCE_DIR}/xenia_main_ios.mm + ${PROJECT_SOURCE_DIR}/src/xenia/ui/ios/app/windowed_app_main_ios.mm + ) + set_target_properties(xenia-app PROPERTIES + OUTPUT_NAME "XeniOS" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info_ios.plist" + MACOSX_BUNDLE_BUNDLE_NAME "XeniOS" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.xenios" + MACOSX_BUNDLE_BUNDLE_VERSION "1" + MACOSX_BUNDLE_SHORT_VERSION_STRING "1.0" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.xenios" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS + "${_xe_ios_entitlements}") + target_sources(xenia-app PRIVATE "${_xe_ios_entitlements}") + target_compile_definitions(xenia-app PRIVATE + XBYAK_NO_OP_NAMES + XBYAK_ENABLE_OMITTED_OPERAND + ) + target_link_libraries(xenia-app PRIVATE + xenia-apu + xenia-apu-nop + xenia-apu-sdl + xenia-base + xenia-core + xenia-cpu + xenia-cpu-backend-a64 + xenia-gpu + xenia-gpu-metal + xenia-helper-sdl + xenia-hid + xenia-hid-nop + xenia-hid-sdl + xenia-hid-touch + xenia-kernel + xenia-patcher + xenia-ui + xenia-ui-ios + xenia-ui-metal + xenia-vfs + aes_128 + capstone + dxbc + fmt + imgui + libavcodec + libavformat + libavutil + mspack + snappy + xxhash + "-framework AVFoundation" + "-framework AudioToolbox" + "-framework CoreFoundation" + "-framework Foundation" + "-framework GameController" + "-framework Metal" + "-framework MetalKit" + "-framework QuartzCore" + "-framework Security" + "-framework UIKit" + "-framework UniformTypeIdentifiers" + ) + if(XENIA_ENABLE_IOS_MOLTENVK) + target_link_libraries(xenia-app PRIVATE + xenia-gpu-vulkan + xenia-ui-vulkan + xenia-third-party-moltenvk + glslang-spirv + ) + endif() + target_link_options(xenia-app PRIVATE + "-Wl,-u,___clear_cache" + "-Wl,-rpath,@executable_path/Frameworks" + "-Wl,-rpath,@loader_path/Frameworks" + ) + set(_xe_ios_embedded_dylibs) + if(XENIA_ENABLE_IOS_MOLTENVK) + set(_xe_moltenvk_ios_framework + "${PROJECT_SOURCE_DIR}/third_party/MoltenVK/ios/MoltenVK.framework") + if(XCODE) + list(APPEND _xe_ios_embedded_dylibs "${_xe_moltenvk_ios_framework}") + else() + add_custom_command(TARGET xenia-app POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory + "$/Frameworks" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${_xe_moltenvk_ios_framework}" + "$/Frameworks/MoltenVK.framework" + VERBATIM) + endif() + endif() + foreach(_xe_app_dylib xenia-third-party-metal-shader-converter + xenia-third-party-dxilconv) + if(TARGET ${_xe_app_dylib}) + if(XCODE) + get_target_property(_xe_app_dylib_path ${_xe_app_dylib} + IMPORTED_LOCATION_RELEASE) + if(NOT _xe_app_dylib_path) + get_target_property(_xe_app_dylib_path ${_xe_app_dylib} + IMPORTED_LOCATION) + endif() + if(_xe_app_dylib_path) + list(APPEND _xe_ios_embedded_dylibs "${_xe_app_dylib_path}") + endif() + else() + add_custom_command(TARGET xenia-app POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory + "$/Frameworks" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/Frameworks/$" + VERBATIM) + endif() + endif() + endforeach() + if(XCODE AND _xe_ios_embedded_dylibs) + set_target_properties(xenia-app PROPERTIES + XCODE_EMBED_FRAMEWORKS "${_xe_ios_embedded_dylibs}" + XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY YES) + endif() + target_include_directories(xenia-app PRIVATE + "${PROJECT_SOURCE_DIR}/third_party/rapidjson/include" + ) + xe_embed_binary_assets(xenia-app "${PROJECT_SOURCE_DIR}/assets/icons" icons) + xe_embed_compressed_bundle(xenia-app + "${PROJECT_SOURCE_DIR}/build/data_repos/xenia-manager-database/data/game-compatibility" + game_compat) + set(_xe_ios_ad_hoc_sign_command + codesign --force --deep --sign - + --entitlements "${_xe_ios_entitlements}" + "$") + if(NOT XCODE) + add_custom_command(TARGET xenia-app POST_BUILD + COMMAND ${_xe_ios_ad_hoc_sign_command} + VERBATIM) + endif() + set(_xe_ios_package_command + /bin/bash "${PROJECT_SOURCE_DIR}/tools/package_ios_ipa.sh" + "$" + "${_xe_ios_entitlements}" + "$/../XeniOS.ipa" + "$/../XeniOS.requested-entitlements.plist" + "$/../XeniOS.signed-entitlements.plist") + add_custom_command(TARGET xenia-app POST_BUILD + COMMAND ${_xe_ios_package_command} + VERBATIM) + if(XCODE) + add_custom_target(xenia-app-ios-package + COMMAND ${_xe_ios_package_command} + DEPENDS xenia-app + VERBATIM) + else() + add_custom_target(xenia-app-ios-package + COMMAND ${_xe_ios_ad_hoc_sign_command} + COMMAND ${_xe_ios_package_command} + DEPENDS xenia-app + VERBATIM) + endif() + xe_target_defaults(xenia-app) + return() +endif() + if(WIN32) add_executable(xenia-app WIN32) elseif(APPLE) diff --git a/src/xenia/app/Info_ios.plist b/src/xenia/app/Info_ios.plist new file mode 100644 index 000000000..18965997b --- /dev/null +++ b/src/xenia/app/Info_ios.plist @@ -0,0 +1,127 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + Xbox 360 Disc Image + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.iso-image + public.disk-image + + + + CFBundleTypeExtensions + + xex + zar + + CFBundleTypeName + Xbox 360 Executable + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.data + + + + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + jp.xenios.xenios.launch + CFBundleURLSchemes + + xenios + + + + LSApplicationQueriesSchemes + + stikjit + + LSSupportsGameMode + + LSSupportsOpeningDocumentsInPlace + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + XeniaSceneDelegate + + + + + UIFileSharingEnabled + + UILaunchScreen + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + metal + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportsDocumentBrowser + + + diff --git a/src/xenia/app/directory_scanner.cc b/src/xenia/app/directory_scanner.cc index 5bb187d03..83a99d973 100644 --- a/src/xenia/app/directory_scanner.cc +++ b/src/xenia/app/directory_scanner.cc @@ -19,6 +19,7 @@ #include "third_party/stb/stb_image.h" #include "xenia/base/filesystem.h" #include "xenia/base/logging.h" +#include "xenia/base/threading.h" #include "xenia/vfs/iso_metadata.h" #include "xenia/vfs/stfs_metadata.h" #include "xenia/vfs/xex_metadata.h" @@ -173,6 +174,10 @@ size_t DirectoryScanner::CountResultsWithTitleId( } void DirectoryScanner::Run(std::filesystem::path root) { +#if XE_PLATFORM_IOS + xe::threading::set_name("Directory Scanner"); + xe::threading::set_current_thread_qos(xe::threading::ThreadQoS::kUtility); +#endif XELOGI("DirectoryScanner: scan start, root='{}'", xe::path_to_utf8(root)); std::error_code ec; if (!std::filesystem::is_directory(root, ec) || ec) { @@ -219,6 +224,10 @@ void DirectoryScanner::Run(std::filesystem::path root) { } void DirectoryScanner::WorkerLoop() { +#if XE_PLATFORM_IOS + xe::threading::set_name("Directory Scanner Worker"); + xe::threading::set_current_thread_qos(xe::threading::ThreadQoS::kUtility); +#endif while (true) { std::filesystem::path dir; { diff --git a/src/xenia/app/xenia_main_ios.mm b/src/xenia/app/xenia_main_ios.mm new file mode 100644 index 000000000..f71645b75 --- /dev/null +++ b/src/xenia/app/xenia_main_ios.mm @@ -0,0 +1,1544 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/filesystem.h" +#include "xenia/base/logging.h" +#include "xenia/base/string.h" +#include "xenia/base/threading.h" +#include "xenia/config.h" +#include "xenia/cpu/processor.h" +#include "xenia/emulator.h" +#include "xenia/gpu/gpu_flags.h" +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#include "xenia/ui/ios/game/window_ios.h" +#include "xenia/ui/ios/shared/ios_system_utils.h" +#include "xenia/ui/presenter.h" +#include "xenia/ui/window.h" +#include "xenia/ui/windowed_app.h" + +// Graphics system. +#include "xenia/gpu/metal/metal_graphics_system.h" +#if defined(XE_IOS_MOLTENVK_ENABLED) +#include "xenia/gpu/vulkan/vulkan_graphics_system.h" +#endif + +// Audio systems. +#include "xenia/apu/sdl/sdl_audio_system.h" + +// Input drivers. +#include "xenia/hid/input_system.h" +#include "xenia/hid/nop/nop_hid.h" +#include "xenia/hid/sdl/sdl_hid.h" +#include "xenia/hid/touch/touch_hid_ios.h" +#include "xenia/kernel/kernel_state.h" +#include "xenia/kernel/xam/achievement_manager.h" +#include "xenia/kernel/xam/xam.h" +#include "xenia/kernel/xam/xam_module.h" +#include "xenia/kernel/xam/xam_state.h" +#include "xenia/patcher/patch_db.h" + +DECLARE_bool(present_letterbox); + +// CVars normally defined in xenia_main.cc (excluded on iOS). +DEFINE_path(storage_root, "", + "Root path for persistent internal data storage (config, etc.), or empty " + "to use the path preferred for the OS.", + "Storage"); +DEFINE_path(content_root, "", + "Root path for guest content storage (saves, etc.), or empty to use the " + "content folder under the storage root.", + "Storage"); +DEFINE_path(cache_root, "", + "Root path for cache files. If empty, the cache folder under the storage " + "root will be used.", + "Storage"); +DEFINE_string(apu, "sdl", "Audio system. Use: [sdl, nop]", "APU"); +#if defined(XE_IOS_MOLTENVK_ENABLED) +DEFINE_string(gpu, "metal", "Graphics system. Use: [metal, vulkan]", "GPU"); +#else +DEFINE_string(gpu, "metal", "Graphics system. Use: [metal]", "GPU"); +#endif +DEFINE_bool(mount_scratch, false, "Enable scratch mount", "Storage"); +DEFINE_bool(mount_cache, true, "Enable cache mount", "Storage"); +DEFINE_bool(mount_memory_unit, false, "Enable memory unit (MU) mount", "Storage"); +DEFINE_bool(ios_emulator_thread_user_initiated_qos, false, + "Run the iOS emulator host thread at user-initiated QoS. This is an " + "iOS scheduler experiment for devices where guest CPU work falls behind.", + "iOS"); + +// CVar normally defined in windowed_app_main_qt.cc (excluded on iOS). +DEFINE_transient_path(target, "", "Specifies the target file to run.", "General"); +DECLARE_string(launch_module); +DECLARE_uint32(launch_flags); +DECLARE_string(launch_data); + +namespace xe { +namespace app { + +namespace { + +std::string NSErrorDescription(NSError* error) { + if (!error) { + return "unknown error"; + } + NSString* description = [error localizedDescription]; + return description ? std::string([description UTF8String]) : "unknown error"; +} + +void ConfigureAudioSession() { + static std::once_flag once; + std::call_once(once, []() { + @autoreleasepool { + AVAudioSession* session = [AVAudioSession sharedInstance]; + NSError* error = nil; + + if (![session setCategory:AVAudioSessionCategoryPlayback + mode:AVAudioSessionModeDefault + options:0 + error:&error]) { + XELOGW("iOS audio session: setCategory failed: {}", NSErrorDescription(error)); + error = nil; + } + + if (![session setPreferredSampleRate:48000.0 error:&error]) { + XELOGW("iOS audio session: setPreferredSampleRate failed: {}", NSErrorDescription(error)); + error = nil; + } + + constexpr NSTimeInterval kPreferredIOBufferDuration = 0.02; + if (![session setPreferredIOBufferDuration:kPreferredIOBufferDuration error:&error]) { + XELOGW("iOS audio session: setPreferredIOBufferDuration failed: {}", + NSErrorDescription(error)); + error = nil; + } + + if (![session setActive:YES error:&error]) { + XELOGW("iOS audio session: activation failed: {}", NSErrorDescription(error)); + error = nil; + } + + XELOGI("iOS audio session: category={}, sample_rate={}, " + "preferred_sample_rate={}, io_buffer={}, preferred_io_buffer={}", + [[session category] UTF8String], [session sampleRate], [session preferredSampleRate], + [session IOBufferDuration], [session preferredIOBufferDuration]); + } + }); +} + +std::filesystem::path IOSPatchesDirectory(const std::filesystem::path& storage_root) { + return storage_root / "patches"; +} + +std::filesystem::path IOSPatchStoragePath(const std::filesystem::path& storage_root, + const patcher::BundledPatchFile& patch_file) { + return IOSPatchesDirectory(storage_root) / patch_file.filename; +} + +std::string ReadTextFile(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) { + return {}; + } + std::stringstream contents; + contents << file.rdbuf(); + return contents.str(); +} + +std::string DisplayNameForPatchFile(const patcher::BundledPatchFile& bundled) { + std::string display_name = bundled.filename; + constexpr std::string_view kPatchSuffix = ".patch.toml"; + if (display_name.ends_with(kPatchSuffix)) { + display_name.resize(display_name.size() - kPatchSuffix.size()); + } + size_t separator = display_name.find(" - "); + if (separator != std::string::npos) { + display_name = display_name.substr(separator + 3); + } + return display_name; +} + +bool IsPatchFileName(const std::string& filename) { + static const std::regex patch_filename_regex("^[A-Fa-f0-9]{8}.*\\.patch\\.toml$"); + return std::regex_match(filename, patch_filename_regex); +} + +void EnsureIOSPatchesDirectoryExposed(const std::filesystem::path& storage_root) { + const std::filesystem::path patches_dir = IOSPatchesDirectory(storage_root); + std::error_code ec; + std::filesystem::create_directories(patches_dir, ec); + if (ec) { + XELOGW("iOS patches: failed creating Documents patches folder {}: {}", patches_dir, + ec.message()); + return; + } + + size_t copied_files = 0; + size_t existing_files = 0; + size_t failed_files = 0; + for (const patcher::BundledPatchFile& bundled : patcher::EnumerateBundledPatches()) { + const std::filesystem::path storage_path = IOSPatchStoragePath(storage_root, bundled); + ec.clear(); + if (std::filesystem::exists(storage_path, ec)) { + ++existing_files; + continue; + } + if (ec) { + ++failed_files; + XELOGW("iOS patches: failed checking {}: {}", storage_path, ec.message()); + continue; + } + + std::ofstream file(storage_path, std::ios::binary | std::ios::trunc); + if (!file.is_open()) { + ++failed_files; + XELOGW("iOS patches: failed opening {} for write", storage_path); + continue; + } + file.write(bundled.toml_content.data(), + static_cast(bundled.toml_content.size())); + if (!file.good()) { + ++failed_files; + XELOGW("iOS patches: failed writing {}", storage_path); + continue; + } + ++copied_files; + } + + XELOGI("iOS patches: exposed Documents patches folder {} (copied={}, " + "existing={}, failed={})", + patches_dir, copied_files, existing_files, failed_files); +} + +std::vector EnumerateIOSPatchFilesForTitle( + const std::filesystem::path& storage_root, uint32_t title_id, + ui::IOSPatchDiscoverySummary* summary_out = nullptr) { + std::vector patch_files = + patcher::EnumerateBundledPatchesForTitle(title_id); + if (summary_out) { + summary_out->directory_path = + xe::path_to_utf8(IOSPatchesDirectory(storage_root).lexically_normal()); + summary_out->bundled_files = patch_files.size(); + } + if (!title_id) { + return patch_files; + } + + const std::filesystem::path patches_dir = IOSPatchesDirectory(storage_root); + if (!std::filesystem::exists(patches_dir) || !std::filesystem::is_directory(patches_dir)) { + return patch_files; + } + if (summary_out) { + summary_out->directory_exists = true; + } + + patcher::PatchDB scratch{std::filesystem::path()}; + for (const auto& file_info : xe::filesystem::ListFiles(patches_dir)) { + if (file_info.type != xe::filesystem::FileInfo::Type::kFile) { + continue; + } + if (summary_out) { + ++summary_out->scanned_files; + } + + const std::string filename = xe::path_to_utf8(file_info.name); + if (!IsPatchFileName(filename)) { + continue; + } + if (summary_out) { + ++summary_out->candidate_files; + } + + const std::filesystem::path path = file_info.path / file_info.name; + const std::string toml_content = ReadTextFile(path); + patcher::PatchFileEntry entry = scratch.ReadPatchFromString(filename, toml_content); + if (entry.title_id == static_cast(-1)) { + if (summary_out) { + ++summary_out->parse_failures; + } + continue; + } + if (entry.title_id != title_id) { + if (summary_out) { + ++summary_out->title_mismatches; + } + continue; + } + entry.filename = filename; + if (summary_out) { + ++summary_out->matching_files; + } + + patcher::BundledPatchFile local_patch_file; + local_patch_file.filename = filename; + local_patch_file.toml_content = toml_content; + local_patch_file.entry = std::move(entry); + + auto existing = std::find_if(patch_files.begin(), patch_files.end(), + [&](const patcher::BundledPatchFile& candidate) { + return candidate.filename == local_patch_file.filename; + }); + if (existing != patch_files.end()) { + *existing = std::move(local_patch_file); + } else { + patch_files.push_back(std::move(local_patch_file)); + } + } + + return patch_files; +} + +std::vector SplitPatchLines(const std::string& source_text) { + std::vector lines; + std::stringstream stream(source_text); + std::string line; + while (std::getline(stream, line)) { + lines.push_back(std::move(line)); + } + if (!source_text.empty() && source_text.back() == '\n') { + lines.emplace_back(); + } + return lines; +} + +bool UpdatePatchEnabledLine(std::vector* lines, size_t patch_index, bool new_value) { + if (!lines) { + return false; + } + + static const std::regex patch_header(R"(^\s*\[\[patch\]\]\s*$)"); + static const std::regex section_header(R"(^\s*\[([^\[\]]+)\]\s*$)"); + static const std::regex is_enabled(R"(^\s*is_enabled\s*=\s*(.+))"); + static const std::regex comment_line(R"(^\s*#.*)"); + static const std::regex value_regex(R"((^\s*is_enabled\s*=\s*)(true|false)(.*)$)"); + + std::vector enabled_lines; + bool in_patch = false; + for (size_t i = 0; i < lines->size(); ++i) { + const std::string& line = (*lines)[i]; + if (std::regex_match(line, comment_line)) { + continue; + } + if (std::regex_match(line, patch_header)) { + in_patch = true; + continue; + } + if (std::regex_match(line, section_header)) { + in_patch = false; + continue; + } + if (in_patch && std::regex_search(line, is_enabled)) { + enabled_lines.push_back(i); + } + } + if (patch_index >= enabled_lines.size()) { + return false; + } + + std::smatch match; + std::string& line = (*lines)[enabled_lines[patch_index]]; + if (!std::regex_match(line, match, value_regex)) { + return false; + } + line = match[1].str() + (new_value ? "true" : "false") + match[3].str(); + return true; +} + +std::string JoinPatchLines(const std::vector& lines) { + std::stringstream stream; + for (size_t i = 0; i < lines.size(); ++i) { + stream << lines[i]; + if (i + 1 < lines.size()) { + stream << '\n'; + } + } + return stream.str(); +} + +bool WritePatchTextAtomically(const std::filesystem::path& path, const std::string& text, + std::string* status) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + if (status) { + *status = "Failed creating patch directory: " + ec.message(); + } + return false; + } + + std::filesystem::path temp_path = path; + temp_path += ".tmp"; + { + std::ofstream out(temp_path, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + if (status) { + *status = "Failed opening patch file for writing."; + } + return false; + } + out << text; + out.close(); + if (!out) { + std::filesystem::remove(temp_path, ec); + if (status) { + *status = "Failed writing patch file."; + } + return false; + } + } + + std::filesystem::rename(temp_path, path, ec); + if (ec) { + std::filesystem::remove(temp_path, ec); + if (status) { + *status = "Failed replacing patch file: " + ec.message(); + } + return false; + } + if (status) { + *status = "Saved. Takes effect on next launch."; + } + return true; +} + +ui::IOSPatchFileSummary BuildIOSPatchFileSummary(const patcher::BundledPatchFile& bundled, + const std::filesystem::path& storage_root) { + ui::IOSPatchFileSummary summary; + summary.title_id = bundled.entry.title_id; + summary.filename = bundled.filename; + summary.display_name = DisplayNameForPatchFile(bundled); + summary.title_name = bundled.entry.title_name; + + const std::filesystem::path storage_path = IOSPatchStoragePath(storage_root, bundled); + std::string source_text = + std::filesystem::exists(storage_path) ? ReadTextFile(storage_path) : bundled.toml_content; + toml::table root; + try { + root = toml::parse(source_text); + } catch (const std::exception& e) { + XELOGE("iOS patches: failed to parse {}: {}", bundled.filename, e.what()); + return summary; + } + + auto* patches = root["patch"].as_array(); + if (!patches) { + return summary; + } + + size_t patch_index = 0; + for (const auto& node : *patches) { + auto* table = node.as_table(); + if (!table) { + ++patch_index; + continue; + } + ui::IOSPatchEntrySummary patch; + patch.patch_index = patch_index++; + if (auto value = (*table)["name"].value()) { + patch.name = *value; + } + if (auto value = (*table)["desc"].value()) { + patch.description = *value; + } + if (auto value = (*table)["author"].value()) { + patch.author = *value; + } + if (auto value = (*table)["is_enabled"].value()) { + patch.is_enabled = *value; + } + summary.patches.push_back(std::move(patch)); + } + return summary; +} + +} // namespace + +class EmulatorAppIOS final : public xe::ui::WindowedApp { + public: + static std::unique_ptr Create(xe::ui::WindowedAppContext& app_context) { + return std::unique_ptr(new EmulatorAppIOS(app_context)); + } + + bool OnInitialize() override; + void OnDestroy() override; + + private: + static constexpr size_t kZOrderHidInput = 128; + + explicit EmulatorAppIOS(xe::ui::WindowedAppContext& app_context) + : xe::ui::WindowedApp(app_context, "xenia") {} + + std::filesystem::path GetIOSStorageRoot() const; + std::filesystem::path GetStorageRootForCallbacks() const; + bool RequestGameStop(const char* reason); + void ClearGameplayInputBlockerIfApplied(); + void ClearPresenterForTitleExit(); + void StartEmulatorThread(std::filesystem::path game_path, bool require_cpu_backend); + void StartQueuedLaunchIfIdle(); + void EmulatorThread(const std::filesystem::path& game_path, bool require_cpu_backend); + void ApplyGuestDisplayRefreshCapToWindowFromUIThread(bool capped); + bool EnsureProfileServicesReady(); + + static std::unique_ptr CreateAudioSystem(cpu::Processor* processor); + static std::unique_ptr CreateGraphicsSystem(); + static std::vector> CreateInputDrivers(ui::Window* window); + + std::unique_ptr emulator_; + std::filesystem::path storage_root_; + std::unique_ptr window_; + std::thread emulator_thread_; + std::atomic emulator_thread_running_{false}; + std::atomic game_stop_requested_{false}; + std::atomic shutting_down_{false}; + std::atomic gameplay_input_blocked_{false}; + std::atomic gameplay_input_blocker_applied_{false}; + std::mutex launch_mutex_; + std::optional queued_launch_path_; + bool title_update_install_in_progress_ = false; + std::atomic emulator_initialized_{false}; + std::atomic emulator_cpu_initialized_{false}; +}; + +bool EmulatorAppIOS::EnsureProfileServicesReady() { + if (emulator_ && emulator_->kernel_state() && emulator_->kernel_state()->xam_state()) { + return true; + } + + if (!shutting_down_.load(std::memory_order_acquire) && + !emulator_thread_running_.load(std::memory_order_acquire) && + !emulator_initialized_.load(std::memory_order_acquire)) { + StartEmulatorThread({}, false); + } + + // Do not block here; callers may execute on UI-sensitive paths. + return emulator_ && emulator_->kernel_state() && emulator_->kernel_state()->xam_state(); +} + +void EmulatorAppIOS::ApplyGuestDisplayRefreshCapToWindowFromUIThread(bool capped) { + if (!window_) { + return; + } + static_cast(window_.get())->SetGuestDisplayRefreshCapFromUIThread(capped); +} + +std::filesystem::path EmulatorAppIOS::GetIOSStorageRoot() const { +#if XE_PLATFORM_IOS + // On iOS, use the app's Documents directory as the storage root. + // This is accessible via Files.app and iTunes file sharing. + return xe_get_ios_documents_path(); +#else + return xe::filesystem::GetUserFolder() / "Xenia"; +#endif +} + +std::filesystem::path EmulatorAppIOS::GetStorageRootForCallbacks() const { + if (!storage_root_.empty()) { + return storage_root_; + } + if (emulator_) { + return emulator_->storage_root(); + } + return std::filesystem::absolute(GetIOSStorageRoot()); +} + +bool EmulatorAppIOS::OnInitialize() { + ConfigureAudioSession(); + + // Set up storage paths. + std::filesystem::path storage_root = cvars::storage_root; + if (storage_root.empty()) { + storage_root = GetIOSStorageRoot(); + } + storage_root = std::filesystem::absolute(storage_root); + storage_root_ = storage_root; + + // Create storage directories if they don't exist. + std::filesystem::create_directories(storage_root); + EnsureIOSPatchesDirectoryExposed(storage_root); + XELOGI("iOS: Storage root: {}", storage_root); + + config::SetupConfig(storage_root); + + std::filesystem::path content_root = cvars::content_root; + if (content_root.empty()) { + content_root = storage_root / "content"; + } else if (!content_root.is_absolute()) { + content_root = storage_root / content_root; + } + content_root = std::filesystem::absolute(content_root); + std::filesystem::create_directories(content_root); + XELOGI("iOS: Content root: {}", content_root); + + std::filesystem::path cache_root = cvars::cache_root; + if (cache_root.empty()) { +#if XE_PLATFORM_IOS + @autoreleasepool { + NSArray* cache_paths = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + if (cache_paths.count > 0) { + cache_root = std::filesystem::path([cache_paths[0] UTF8String]) / "xenia"; + } else { + cache_root = storage_root / "cache_host"; + } + } +#else + cache_root = storage_root / "cache_host"; +#endif + } else if (!cache_root.is_absolute()) { + cache_root = storage_root / cache_root; + } + cache_root = std::filesystem::absolute(cache_root); + std::filesystem::create_directories(cache_root); + XELOGI("iOS: Cache root: {}", cache_root); + + // Create the emulator instance. + emulator_ = std::make_unique("", storage_root, content_root, cache_root); + + // iOS title-to-title relaunch flow: + // 1. XamLoaderLaunchTitle invokes this callback with relaunch parameters. + // 2. We queue the resolved target and preserve launch metadata in cvars. + // 3. XamLoaderLaunchTitle then terminates the current title. + // 4. Emulator thread exit triggers StartQueuedLaunchIfIdle(). + emulator_->set_on_launch_new_title([this](const std::string& host_path, + const std::string& launch_module, uint32_t launch_flags, + const std::string& launch_data_hex) { + if (shutting_down_.load(std::memory_order_acquire)) { + XELOGW("iOS: Ignoring title relaunch request while shutting down"); + return; + } + + std::filesystem::path relaunch_target; + if (!host_path.empty()) { + relaunch_target = std::filesystem::path(host_path); + } + if (!launch_module.empty()) { + std::filesystem::path launch_module_path(launch_module); + if (relaunch_target.empty()) { + relaunch_target = launch_module_path; + } else if (relaunch_target.extension() == ".xex" || relaunch_target.extension() == ".XEX") { + relaunch_target = relaunch_target.parent_path() / launch_module_path; + } else { + relaunch_target = relaunch_target / launch_module_path; + } + } + relaunch_target = relaunch_target.lexically_normal(); + if (relaunch_target.empty()) { + XELOGW("iOS: Ignoring title relaunch request with empty target " + "(host_path='{}', launch_module='{}')", + host_path, launch_module); + return; + } + + { + std::lock_guard lock(launch_mutex_); + queued_launch_path_ = relaunch_target; + } + + cvars::launch_module = launch_module; + cvars::launch_flags = launch_flags; + cvars::launch_data = launch_data_hex; + + XELOGI("iOS: Queued title relaunch target='{}' module='{}' flags={} " + "data_len={}", + relaunch_target.string(), launch_module, launch_flags, launch_data_hex.size()); + }); + + emulator_->on_launch.AddListener([](uint32_t title_id, const std::string_view title_name) { + if (title_id == 0 || title_name.empty()) { + return; + } + @autoreleasepool { + NSString* caches = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + if (!caches) { + return; + } + NSString* path = [caches stringByAppendingPathComponent:@"title-names.plist"]; + NSMutableDictionary* dict = [NSMutableDictionary dictionaryWithContentsOfFile:path]; + if (!dict) { + dict = [NSMutableDictionary dictionary]; + } + NSString* key = [NSString stringWithFormat:@"%08x", title_id]; + NSString* value = [[NSString alloc] initWithBytes:title_name.data() + length:title_name.size() + encoding:NSUTF8StringEncoding]; + if (value && key) { + [dict setObject:value forKey:key]; + [dict writeToFile:path atomically:YES]; + } + [value release]; + } + }); + + // Create the display window from the Metal view in the app context. + window_ = ui::Window::Create(app_context(), "Xenia", 1280, 720); + if (!window_ || !window_->Open()) { + XELOGE("iOS: Failed to create or open display window"); + return false; + } + + XELOGI("iOS: Display window created ({}x{})", window_->GetActualPhysicalWidth(), + window_->GetActualPhysicalHeight()); + ApplyGuestDisplayRefreshCapToWindowFromUIThread(cvars::guest_display_refresh_cap); + + // Register callbacks with the app context. + auto& ios_context = static_cast(app_context()); + + ios_context.set_guest_display_refresh_cap_getter( + []() { return cvars::guest_display_refresh_cap; }); + ios_context.set_guest_display_refresh_cap_setter([this](bool capped) { + SetGuestDisplayRefreshCap(capped); + app_context().CallInUIThread( + [this, capped]() { ApplyGuestDisplayRefreshCapToWindowFromUIThread(capped); }); + std::lock_guard lock(launch_mutex_); + config::SaveGameConfigSetting(emulator_.get(), "GPU", "guest_display_refresh_cap", capped); + }); + + ios_context.set_present_letterbox_getter([]() { return cvars::present_letterbox; }); + ios_context.set_present_letterbox_setter([this](bool enabled) { + cvars::present_letterbox = enabled; + std::lock_guard lock(launch_mutex_); + config::SaveGameConfigSetting(emulator_.get(), "Display", "present_letterbox", enabled); + }); + + ios_context.set_guest_display_aspect_ratio_callback([this]() { + auto* graphics_system = emulator_ ? emulator_->graphics_system() : nullptr; + return graphics_system ? graphics_system->GetScaledAspectRatio() + : std::pair{16, 9}; + }); + + // Forward layout changes (rotation, resize) to the window. + ios_context.set_layout_changed_callback([this]() { + if (window_) { + static_cast(window_.get())->HandleSizeChange(); + } + }); + + ios_context.set_game_launch_callback([this](const std::string& path) { + if (shutting_down_.load(std::memory_order_acquire)) { + XELOGW("iOS: Ignoring game launch request while shutting down"); + return; + } + + if (path.empty()) { + XELOGI("iOS: Profile services init requested"); + } else { + XELOGI("iOS: Game launch requested: {}", path); + } + auto game_path = std::filesystem::path(path); + if (!game_path.empty()) { + // User-driven launches should not reuse prior relaunch metadata. + cvars::launch_module = ""; + cvars::launch_flags = 0; + cvars::launch_data = ""; + } + + bool should_queue_launch = false; + { + std::lock_guard lock(launch_mutex_); + if (title_update_install_in_progress_) { + XELOGW("iOS: Ignoring game launch while title update installation " + "is in progress"); + return; + } + if (emulator_thread_running_.load(std::memory_order_acquire)) { + queued_launch_path_ = game_path; + should_queue_launch = true; + } + } + if (should_queue_launch) { + XELOGI("iOS: Emulator thread is running; queued launch '{}'", game_path.string()); + if (game_stop_requested_.load(std::memory_order_acquire)) { + XELOGI("iOS: Current game stop already in progress; queued launch will " + "start after teardown"); + return; + } + RequestGameStop("Queued launch"); + return; + } + + const bool require_cpu_backend = !game_path.empty(); + StartEmulatorThread(std::move(game_path), require_cpu_backend); + }); + + ios_context.set_game_terminate_callback( + [this]() { return RequestGameStop("TerminateCurrentGame"); }); + + ios_context.set_title_update_install_callback([this](const std::string& package_path, + std::string* status_out, + bool* not_title_update_out) { + auto set_status = [&](const std::string& status) { + if (status_out) { + *status_out = status; + } + }; + auto set_not_title_update = [&](bool not_title_update) { + if (not_title_update_out) { + *not_title_update_out = not_title_update; + } + }; + set_not_title_update(false); + + if (package_path.empty()) { + set_status("No title update package selected."); + return false; + } + if (shutting_down_.load(std::memory_order_acquire)) { + set_status("App is shutting down."); + return false; + } + + { + std::lock_guard lock(launch_mutex_); + if (!emulator_) { + set_status("Emulator is not initialized."); + return false; + } + if (title_update_install_in_progress_) { + set_status("Another title update installation is already running."); + return false; + } + if (emulator_thread_running_.load(std::memory_order_acquire)) { + set_status("Stop the current game before installing a title update."); + return false; + } + title_update_install_in_progress_ = true; + } + + struct TitleUpdateInstallScope { + EmulatorAppIOS* app = nullptr; + ~TitleUpdateInstallScope() { + if (!app) { + return; + } + std::lock_guard lock(app->launch_mutex_); + app->title_update_install_in_progress_ = false; + } + } install_scope{this}; + + if (!EnsureProfileServicesReady()) { + set_status("Profile services are still initializing. Please try again."); + return false; + } + + Emulator::ContentInstallEntry entry{std::filesystem::path(package_path)}; + X_STATUS parse_status = X_STATUS_UNSUCCESSFUL; + X_STATUS install_status = X_STATUS_UNSUCCESSFUL; + { + std::lock_guard lock(launch_mutex_); + if (shutting_down_.load(std::memory_order_acquire)) { + set_status("App is shutting down."); + return false; + } + if (!emulator_) { + set_status("Emulator is unavailable."); + return false; + } + parse_status = emulator_->ProcessContentPackageHeader(entry.path_, entry); + } + if (XFAILED(parse_status) || entry.installation_state_ == Emulator::InstallState::failed) { + set_not_title_update(true); + set_status("Selected file is not a title update package."); + return false; + } + + if (entry.content_type_ != XContentType::kInstaller) { + set_not_title_update(true); + set_status("Selected file is not a title update package."); + return false; + } + + { + std::lock_guard lock(launch_mutex_); + if (shutting_down_.load(std::memory_order_acquire)) { + set_status("App is shutting down."); + return false; + } + if (!emulator_) { + set_status("Emulator is unavailable."); + return false; + } + install_status = emulator_->InstallContentPackage(entry.path_, entry); + } + if (XFAILED(install_status) || entry.installation_state_ != Emulator::InstallState::installed) { + std::string reason = entry.installation_error_message_.empty() + ? "Title update installation failed." + : entry.installation_error_message_; + set_status(reason); + return false; + } + + const std::string installed_name = + entry.name_.empty() ? std::filesystem::path(package_path).filename().string() : entry.name_; + set_status("Installed title update: " + installed_name); + return true; + }); + + ios_context.set_controller_state_callback([this](uint32_t user_index, + hid::X_INPUT_STATE* out_state) { + std::lock_guard lock(launch_mutex_); + if (!out_state || !emulator_ || !emulator_->input_system()) { + return false; + } + return emulator_->input_system()->GetStateForUI(user_index, 1, out_state) == X_ERROR_SUCCESS; + }); + + ios_context.set_gameplay_input_blocked_callback([this](bool blocked) { + std::lock_guard lock(launch_mutex_); + gameplay_input_blocked_.store(blocked, std::memory_order_release); + if (!emulator_ || !emulator_->input_system()) { + gameplay_input_blocker_applied_.store(false, std::memory_order_release); + return; + } + if (blocked) { + if (!gameplay_input_blocker_applied_.exchange(true, std::memory_order_acq_rel)) { + emulator_->input_system()->AddUIInputBlocker(); + } + } else if (gameplay_input_blocker_applied_.exchange(false, std::memory_order_acq_rel)) { + emulator_->input_system()->RemoveUIInputBlocker(); + } + }); + + ios_context.set_profiles_list_callback([this]() { + std::vector profiles; + if (!emulator_ || !emulator_->kernel_state() || !emulator_->kernel_state()->xam_state()) { + return profiles; + } + auto* profile_manager = emulator_->kernel_state()->xam_state()->profile_manager(); + if (!profile_manager) { + return profiles; + } + const auto* accounts = profile_manager->GetAccounts(); + profiles.reserve(accounts->size()); + for (const auto& [xuid, account] : *accounts) { + ui::IOSProfileSummary summary; + summary.xuid = xuid; + summary.gamertag = account.GetGamertagString(); + uint8_t slot = profile_manager->GetUserIndexAssignedToProfile(xuid); + summary.signed_in = slot < XUserMaxUserCount; + summary.signed_in_slot = slot; + profiles.push_back(std::move(summary)); + } + std::sort(profiles.begin(), profiles.end(), + [](const ui::IOSProfileSummary& a, const ui::IOSProfileSummary& b) { + return a.gamertag < b.gamertag; + }); + return profiles; + }); + + ios_context.set_profile_create_callback([this](const std::string& gamertag) -> uint64_t { + if (!emulator_ || !EnsureProfileServicesReady()) { + return uint64_t(0); + } + auto* profile_manager = emulator_->kernel_state()->xam_state()->profile_manager(); + if (!profile_manager) { + return uint64_t(0); + } + if (!xe::kernel::xam::ProfileManager::IsGamertagValid(gamertag)) { + return uint64_t(0); + } + + std::set before_ids; + for (const auto& [xuid, account] : *profile_manager->GetAccounts()) { + before_ids.insert(xuid); + } + + if (!profile_manager->CreateProfile(gamertag, false)) { + return uint64_t(0); + } + + for (const auto& [xuid, account] : *profile_manager->GetAccounts()) { + if (!before_ids.contains(xuid)) { + return xuid; + } + } + return uint64_t(0); + }); + + ios_context.set_profile_sign_in_callback([this](uint64_t xuid) { + if (!emulator_ || !emulator_->kernel_state() || !emulator_->kernel_state()->xam_state()) { + return false; + } + auto* profile_manager = emulator_->kernel_state()->xam_state()->profile_manager(); + if (!profile_manager || !profile_manager->GetAccount(xuid)) { + return false; + } + + for (uint8_t slot = 0; slot < XUserMaxUserCount; ++slot) { + if (profile_manager->GetProfile(slot)) { + profile_manager->Logout(slot, false); + } + } + profile_manager->Login(xuid, 0, true); + return profile_manager->GetProfile(xuid) != nullptr; + }); + + ios_context.set_achievements_snapshot_callback([this](uint32_t user_index, uint32_t title_id) { + ui::IOSAchievementsSnapshot snapshot; + std::lock_guard lock(launch_mutex_); + if (!emulator_ || !emulator_->kernel_state() || !emulator_->kernel_state()->xam_state()) { + return snapshot; + } + + auto* xam_state = emulator_->kernel_state()->xam_state(); + auto* profile = xam_state->GetUserProfile(user_index); + if (!profile) { + return snapshot; + } + + uint32_t resolved_title_id = title_id; + if (!resolved_title_id && xam_state->spa_info()) { + resolved_title_id = xam_state->spa_info()->title_id(); + } + if (!resolved_title_id) { + return snapshot; + } + + snapshot.title_id = resolved_title_id; + auto title_info = + xam_state->user_tracker()->GetUserTitleInfo(profile->xuid(), resolved_title_id); + if (title_info) { + snapshot.title_name = xe::to_utf8(title_info->title_name); + snapshot.achievements_total = title_info->achievements_count; + snapshot.achievements_unlocked = title_info->unlocked_achievements_count; + snapshot.gamerscore_total = title_info->gamerscore_amount; + snapshot.gamerscore_earned = title_info->title_earned_gamerscore; + } + + const auto achievements = + xam_state->achievement_manager()->GetTitleAchievements(profile->xuid(), resolved_title_id); + snapshot.achievements.reserve(achievements.size()); + uint32_t computed_unlocked = 0; + uint32_t computed_total_score = 0; + uint32_t computed_earned_score = 0; + for (const auto& achievement : achievements) { + ui::IOSAchievementEntry entry; + entry.achievement_id = achievement.achievement_id; + entry.gamerscore = achievement.gamerscore; + entry.flags = achievement.flags; + entry.unlocked = achievement.IsUnlocked(); + entry.unlocked_online = achievement.IsUnlockedOnline(); + entry.show_unachieved = + (achievement.flags & + static_cast(kernel::xam::AchievementFlags::kShowUnachieved)) != 0; + entry.title = xe::to_utf8(achievement.achievement_name); + entry.unlocked_description = xe::to_utf8(achievement.unlocked_description); + entry.locked_description = xe::to_utf8(achievement.locked_description); + + const auto icon = xam_state->achievement_manager()->GetAchievementIcon( + profile->xuid(), resolved_title_id, achievement.achievement_id); + entry.icon_data.assign(icon.begin(), icon.end()); + + computed_total_score += achievement.gamerscore; + if (entry.unlocked) { + ++computed_unlocked; + computed_earned_score += achievement.gamerscore; + } + snapshot.achievements.push_back(std::move(entry)); + } + + if (!snapshot.achievements_total) { + snapshot.achievements_total = static_cast(snapshot.achievements.size()); + } + if (!snapshot.achievements_unlocked) { + snapshot.achievements_unlocked = computed_unlocked; + } + if (!snapshot.gamerscore_total) { + snapshot.gamerscore_total = computed_total_score; + } + if (!snapshot.gamerscore_earned) { + snapshot.gamerscore_earned = computed_earned_score; + } + return snapshot; + }); + + ios_context.set_patch_files_list_callback([this](uint32_t title_id) { + std::filesystem::path storage_root; + { + std::lock_guard lock(launch_mutex_); + if (!title_id) { + return std::vector(); + } + storage_root = GetStorageRootForCallbacks(); + } + + std::vector summaries; + auto patch_files = EnumerateIOSPatchFilesForTitle(storage_root, title_id); + summaries.reserve(patch_files.size()); + for (const auto& patch_file : patch_files) { + summaries.push_back(BuildIOSPatchFileSummary(patch_file, storage_root)); + } + std::sort(summaries.begin(), summaries.end(), + [](const ui::IOSPatchFileSummary& a, const ui::IOSPatchFileSummary& b) { + return a.display_name < b.display_name; + }); + return summaries; + }); + + ios_context.set_patch_discovery_summary_callback([this](uint32_t title_id) { + std::filesystem::path storage_root; + { + std::lock_guard lock(launch_mutex_); + storage_root = GetStorageRootForCallbacks(); + } + ui::IOSPatchDiscoverySummary summary; + (void)EnumerateIOSPatchFilesForTitle(storage_root, title_id, &summary); + return summary; + }); + + ios_context.set_patch_set_enabled_callback([this](uint32_t title_id, const std::string& filename, + size_t patch_index, bool enabled, + std::string* status_out) { + auto set_status = [&](const std::string& status) { + if (status_out) { + *status_out = status; + } + }; + if (!title_id || filename.empty()) { + set_status("No patch file was selected."); + return false; + } + + std::filesystem::path storage_root; + { + std::lock_guard lock(launch_mutex_); + storage_root = GetStorageRootForCallbacks(); + } + + auto patch_files = EnumerateIOSPatchFilesForTitle(storage_root, title_id); + auto patch_file_it = std::find_if(patch_files.begin(), patch_files.end(), + [&](const patcher::BundledPatchFile& patch_file) { + return patch_file.filename == filename; + }); + if (patch_file_it == patch_files.end()) { + set_status("Patch file was not found."); + return false; + } + + const std::filesystem::path storage_path = IOSPatchStoragePath(storage_root, *patch_file_it); + std::string source_text = std::filesystem::exists(storage_path) ? ReadTextFile(storage_path) + : patch_file_it->toml_content; + std::vector lines = SplitPatchLines(source_text); + if (!UpdatePatchEnabledLine(&lines, patch_index, enabled)) { + set_status("Failed updating patch state."); + return false; + } + return WritePatchTextAtomically(storage_path, JoinPatchLines(lines), status_out); + }); + + XELOGI("iOS: EmulatorAppIOS initialized successfully"); + return true; +} + +bool EmulatorAppIOS::RequestGameStop(const char* reason) { + if (!emulator_ || !emulator_thread_running_.load(std::memory_order_acquire)) { + game_stop_requested_.store(false, std::memory_order_release); + return false; + } + if (game_stop_requested_.exchange(true, std::memory_order_acq_rel)) { + XELOGI("iOS: {} already in progress", reason ? reason : "Game stop"); + return true; + } + + XELOGI("iOS: {} requested", reason ? reason : "Game stop"); + const X_STATUS terminate_status = emulator_->TerminateTitle(); + if (XFAILED(terminate_status)) { + game_stop_requested_.store(false, std::memory_order_release); + XELOGW("iOS: TerminateTitle failed with status {:08X}", terminate_status); + return false; + } + return true; +} + +void EmulatorAppIOS::ClearGameplayInputBlockerIfApplied() { + std::lock_guard lock(launch_mutex_); + if (!gameplay_input_blocker_applied_.exchange(false, std::memory_order_acq_rel)) { + return; + } + if (emulator_ && emulator_->input_system()) { + emulator_->input_system()->RemoveUIInputBlocker(); + } +} + +void EmulatorAppIOS::ClearPresenterForTitleExit() { + if (!emulator_ || !emulator_->graphics_system()) { + return; + } + + auto* presenter = emulator_->graphics_system()->presenter(); + if (!presenter) { + return; + } + + presenter->RefreshGuestOutput(0, 0, 0, 0, + [](ui::Presenter::GuestOutputRefreshContext&) { return true; }); + + if (!app_context().CallInUIThreadSynchronous( + [presenter]() { presenter->PaintFromUIThread(true); })) { + XELOGW("iOS: failed to clear presenter output before title teardown"); + return; + } +} + +void EmulatorAppIOS::StartEmulatorThread(std::filesystem::path game_path, + bool require_cpu_backend) { + if (emulator_thread_.joinable()) { + emulator_thread_.join(); + } + + game_stop_requested_.store(false, std::memory_order_release); + emulator_thread_ = std::thread([this, game_path = std::move(game_path), require_cpu_backend]() { + emulator_thread_running_.store(true, std::memory_order_release); + EmulatorThread(game_path, require_cpu_backend); + emulator_thread_running_.store(false, std::memory_order_release); + game_stop_requested_.store(false, std::memory_order_release); + app_context().CallInUIThread([this]() { StartQueuedLaunchIfIdle(); }); + }); +} + +void EmulatorAppIOS::StartQueuedLaunchIfIdle() { + if (shutting_down_.load(std::memory_order_acquire) || + emulator_thread_running_.load(std::memory_order_acquire)) { + return; + } + + std::optional queued_path; + { + std::lock_guard lock(launch_mutex_); + if (title_update_install_in_progress_) { + return; + } + if (!queued_launch_path_.has_value()) { + return; + } + queued_path = std::move(queued_launch_path_); + queued_launch_path_.reset(); + } + + if (!queued_path.has_value() || queued_path->empty()) { + return; + } + + XELOGI("iOS: Starting queued game launch: {}", queued_path->string()); + StartEmulatorThread(std::move(*queued_path), true); +} + +void EmulatorAppIOS::OnDestroy() { + XELOGI("iOS: EmulatorAppIOS::OnDestroy invoked"); + shutting_down_.store(true, std::memory_order_release); + auto& ios_context = static_cast(app_context()); + ios_context.set_guest_display_refresh_cap_getter({}); + ios_context.set_guest_display_refresh_cap_setter({}); + ios_context.set_present_letterbox_getter({}); + ios_context.set_present_letterbox_setter({}); + ios_context.set_guest_display_aspect_ratio_callback({}); + { + std::lock_guard lock(launch_mutex_); + queued_launch_path_.reset(); + } + if (emulator_thread_.joinable()) { + if (emulator_thread_running_.load(std::memory_order_acquire)) { + RequestGameStop("OnDestroy"); + } + emulator_thread_.join(); + emulator_thread_running_.store(false, std::memory_order_release); + } + { + std::lock_guard lock(launch_mutex_); + emulator_.reset(); + } + window_.reset(); +} + +void EmulatorAppIOS::EmulatorThread(const std::filesystem::path& game_path, + bool require_cpu_backend) { + xe::threading::set_name("Emulator Thread"); +#if XE_PLATFORM_IOS + xe::threading::set_current_thread_qos(xe::threading::ThreadQoS::kDefault); + if (cvars::ios_emulator_thread_user_initiated_qos) { + if (xe::threading::set_current_thread_qos(xe::threading::ThreadQoS::kUserInitiated)) { + XELOGI("iOS: Emulator Thread QoS set to user-initiated"); + } else { + XELOGW("iOS: Emulator Thread QoS request failed"); + } + } +#endif // XE_PLATFORM_IOS + const bool launched_with_game = !game_path.empty(); + + if (launched_with_game) { + XELOGI("iOS: Loading game config for: {}", game_path.string()); + config::LoadGameConfigForFile(game_path); + app_context().CallInUIThread([this]() { + ApplyGuestDisplayRefreshCapToWindowFromUIThread(cvars::guest_display_refresh_cap); + }); + } + + bool game_exit_notified = false; + auto notify_game_exited = [this, &game_exit_notified]() { + if (game_exit_notified) { + return; + } + game_exit_notified = true; + app_context().CallInUIThread([this]() { + auto& ios_context = static_cast(app_context()); + ios_context.NotifyGameExited(); + }); + }; + + struct ScopeExit { + std::function fn; + ~ScopeExit() { + if (fn) { + fn(); + } + } + } notify_exit{[this, launched_with_game, ¬ify_game_exited]() { + if (!launched_with_game) { + return; + } + { + std::lock_guard lock(launch_mutex_); + if (queued_launch_path_.has_value()) { + XELOGI("iOS: keeping game UI active for queued launch"); + return; + } + } + notify_game_exited(); + }}; + + const bool need_profile_mode_transition = + !emulator_initialized_.load(std::memory_order_acquire) || + (require_cpu_backend && !emulator_cpu_initialized_.load(std::memory_order_acquire)); + + if (need_profile_mode_transition) { + if (emulator_initialized_.load(std::memory_order_acquire) && + !emulator_cpu_initialized_.load(std::memory_order_acquire) && require_cpu_backend) { + XELOGI("iOS: Reinitializing emulator for game mode"); + emulator_->ShutdownForTitleExitIOS(); + } + + X_STATUS setup_result = + emulator_->Setup(window_.get(), + nullptr, // No ImGui drawer on iOS for now. + require_cpu_backend, require_cpu_backend ? CreateAudioSystem : nullptr, + require_cpu_backend ? CreateGraphicsSystem : nullptr, + require_cpu_backend ? CreateInputDrivers : nullptr); + + if (XFAILED(setup_result)) { + XELOGE("iOS: Emulator::Setup failed with status {:08X}", setup_result); + emulator_initialized_.store(false, std::memory_order_release); + emulator_cpu_initialized_.store(false, std::memory_order_release); + if (launched_with_game) { + notify_game_exited(); + } + return; + } + + if (require_cpu_backend) { + X_STATUS subsystem_result = emulator_->SetupSubsystems(); + if (XFAILED(subsystem_result)) { + XELOGE("iOS: Emulator::SetupSubsystems failed with status {:08X}", subsystem_result); + emulator_initialized_.store(false, std::memory_order_release); + emulator_cpu_initialized_.store(false, std::memory_order_release); + if (launched_with_game) { + notify_game_exited(); + } + return; + } + } + + emulator_->MountStandardDrives(); + if (emulator_->input_system()) { + if (gameplay_input_blocked_.load(std::memory_order_acquire)) { + if (!gameplay_input_blocker_applied_.exchange(true, std::memory_order_acq_rel)) { + emulator_->input_system()->AddUIInputBlocker(); + } + } else if (gameplay_input_blocker_applied_.exchange(false, std::memory_order_acq_rel)) { + emulator_->input_system()->RemoveUIInputBlocker(); + } + } + if (auto* fs = emulator_->file_system()) { + std::string cache_target; + if (fs->FindSymbolicLink("cache:", cache_target)) { + XELOGI("iOS: cache: mounted to {}", cache_target); + } else { + XELOGW("iOS: cache: mount missing after MountStandardDrives"); + } + } + + auto* graphics_system = emulator_->graphics_system(); + if (graphics_system) { + auto* ios_context = &static_cast(app_context()); + graphics_system->SetScaledAspectRatioChangedCallback([ios_context](uint32_t, uint32_t) { + ios_context->NotifyGuestDisplayAspectRatioChanged(); + }); + } + auto* presenter = graphics_system ? graphics_system->presenter() : nullptr; + if (presenter && !app_context().CallInUIThreadSynchronous([this, presenter]() { + if (window_) { + window_->SetPresenter(presenter); + } + })) { + XELOGE("iOS: Failed to attach presenter to display window"); + emulator_initialized_.store(false, std::memory_order_release); + emulator_cpu_initialized_.store(false, std::memory_order_release); + if (launched_with_game) { + notify_game_exited(); + } + return; + } + + emulator_initialized_.store(true, std::memory_order_release); + emulator_cpu_initialized_.store(require_cpu_backend, std::memory_order_release); + XELOGI("iOS: Emulator setup complete"); + + if (!launched_with_game) { + app_context().CallInUIThread([this]() { + auto& ios_context = static_cast(app_context()); + ios_context.NotifyProfileServicesReady(); + }); + } + } + + if (!game_path.empty()) { + auto abs_path = std::filesystem::absolute(game_path); + XELOGI("iOS: Launching game: {}", abs_path); + + { + auto xam = emulator_->kernel_state()->GetKernelModule("xam.xex"); + if (xam) { + auto& loader_data = xam->loader_data(); + loader_data.host_path = xe::path_to_utf8(abs_path); + loader_data.launch_data_present = false; + loader_data.launch_flags = 0; + loader_data.launch_data.clear(); + if (cvars::launch_flags != 0 || !cvars::launch_data.empty()) { + loader_data.launch_data_present = true; + loader_data.launch_flags = cvars::launch_flags; + const std::string& hex = cvars::launch_data; + for (size_t i = 0; i + 1 < hex.length(); i += 2) { + std::string byte_str = hex.substr(i, 2); + uint8_t byte = static_cast(std::stoul(byte_str, nullptr, 16)); + loader_data.launch_data.push_back(byte); + } + } + } + } + + auto teardown_title_session = [this]() { + if (auto* kernel_state = emulator_->kernel_state()) { + if (!kernel_state->WaitForTitleThreadsToExitIOS(2000)) { + XELOGW("iOS: title stop did not complete cleanly before emulator " + "teardown"); + } + } + + ClearGameplayInputBlockerIfApplied(); + ClearPresenterForTitleExit(); + + // Let GraphicsSystem::Shutdown drain the Metal command processor before + // its UI-thread presenter reset disconnects the CAMetalLayer surface. + emulator_->ShutdownForTitleExitIOS(); + emulator_initialized_.store(false, std::memory_order_release); + emulator_cpu_initialized_.store(false, std::memory_order_release); + }; + + X_STATUS launch_result = emulator_->LaunchPath(abs_path); + cvars::launch_module = ""; + cvars::launch_flags = 0; + cvars::launch_data = ""; + if (XFAILED(launch_result)) { +#if XE_PLATFORM_IOS + const bool launch_interrupted_by_title_stop = + emulator_->kernel_state() && emulator_->kernel_state()->IsTitleStopRequestedIOS(); + if (launch_result == X_STATUS_PROCESS_IS_TERMINATING || launch_interrupted_by_title_stop) { + XELOGI("iOS: Game launch interrupted by title stop; running title-exit " + "teardown"); + teardown_title_session(); + return; + } +#endif // XE_PLATFORM_IOS + XELOGE("iOS: Failed to launch game: {:08X}", launch_result); + notify_game_exited(); + return; + } + + XELOGI("iOS: Game launched successfully"); + emulator_->WaitUntilExit(); + XELOGI("iOS: Game execution finished (exit wait completed)"); + + teardown_title_session(); + } +} + +std::unique_ptr EmulatorAppIOS::CreateAudioSystem(cpu::Processor* processor) { + // SDL uses CoreAudio on iOS for audio output. + return std::make_unique(processor); +} + +std::unique_ptr EmulatorAppIOS::CreateGraphicsSystem() { + const std::string gpu_implementation_name = cvars::gpu; +#if defined(XE_IOS_MOLTENVK_ENABLED) + if (gpu_implementation_name == "vulkan") { + return std::make_unique(); + } +#endif + if (gpu_implementation_name != "metal") { + XELOGW("iOS: unsupported GPU backend '{}', falling back to Metal", gpu_implementation_name); + } + return std::make_unique(); +} + +std::vector> EmulatorAppIOS::CreateInputDrivers( + ui::Window* window) { + std::vector> drivers; + + auto touch_driver = xe::hid::touch::Create(window, kZOrderHidInput); + if (touch_driver && XSUCCEEDED(touch_driver->Setup())) { + drivers.emplace_back(std::move(touch_driver)); + } else { + XELOGW("iOS: Touch input driver setup failed"); + } + + // SDL uses the GameController framework on iOS for MFi/Bluetooth gamepads. + auto sdl_driver = xe::hid::sdl::Create(window, kZOrderHidInput); + if (sdl_driver && XSUCCEEDED(sdl_driver->Setup())) { + drivers.emplace_back(std::move(sdl_driver)); + } else if (drivers.empty()) { + XELOGW("iOS: SDL input driver setup failed, falling back to nop input"); + drivers.emplace_back(xe::hid::nop::Create(window, kZOrderHidInput)); + } else { + XELOGW("iOS: SDL input driver setup failed"); + } + return drivers; +} + +} // namespace app +} // namespace xe + +XE_DEFINE_WINDOWED_APP(xenia, xe::app::EmulatorAppIOS::Create); diff --git a/src/xenia/apu/audio_system.cc b/src/xenia/apu/audio_system.cc index 2cfa46e8e..e78cb7a40 100644 --- a/src/xenia/apu/audio_system.cc +++ b/src/xenia/apu/audio_system.cc @@ -9,6 +9,8 @@ #include "xenia/apu/audio_system.h" +#include + #include "xenia/apu/apu_flags.h" #include "xenia/apu/audio_driver.h" #include "xenia/apu/xma_decoder.h" @@ -35,7 +37,21 @@ // and let the normal AudioSystem handling take it, to prevent duplicate // implementations. They can be found in xboxkrnl_audio_xma.cc -DEFINE_uint32(apu_max_queued_frames, 8, +namespace { +#if XE_PLATFORM_IOS +constexpr uint32_t kApuQueuedFramesDefault = 32; +constexpr uint32_t kApuQueuedFramesMinimum = 32; + +bool IsTitleStopRequested(xe::cpu::Processor* processor) { + return processor && processor->title_stop_requested_ios(); +} +#else +constexpr uint32_t kApuQueuedFramesDefault = 8; +constexpr uint32_t kApuQueuedFramesMinimum = 4; +#endif // XE_PLATFORM_IOS +} // namespace + +DEFINE_uint32(apu_max_queued_frames, kApuQueuedFramesDefault, "Allows changing max buffered audio frames to reduce audio " "delay. Lowering this value might cause performance issues. " "Value range: [4-64]", @@ -97,11 +113,26 @@ X_STATUS AudioSystem::Setup(kernel::KernelState* kernel_state) { } void AudioSystem::WorkerThreadMain() { +#if XE_PLATFORM_IOS + if (xe::threading::set_current_thread_qos( + xe::threading::ThreadQoS::kUserInteractive)) { + XELOGI("iOS: Audio Worker QoS set to user-interactive"); + } else { + XELOGW("iOS: Audio Worker QoS request failed"); + } +#endif // XE_PLATFORM_IOS + // Initialize driver and ringbuffer. Initialize(); // Main run loop. while (worker_running_) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequested(processor_)) { + break; + } +#endif // XE_PLATFORM_IOS + // These handles signify the number of submitted samples. Once we reach // 64 samples, we wait until our audio backend releases a semaphore // (signaling a sample has finished playing) @@ -112,10 +143,14 @@ void AudioSystem::WorkerThreadMain() { continue; } + if (!worker_running_) { + break; + } + if (result.first == threading::WaitResult::kSuccess && result.second == kMaximumClientCount) { // Shutdown event signaled. - if (paused_) { + if (paused_.load(std::memory_order_acquire)) { pause_fence_.Signal(); threading::Wait(resume_event_.get(), false); } @@ -141,6 +176,12 @@ void AudioSystem::WorkerThreadMain() { } } +#if XE_PLATFORM_IOS + if (IsTitleStopRequested(processor_)) { + break; + } +#endif // XE_PLATFORM_IOS + if (client_callback) { SCOPE_profile_cpu_i("apu", "xe::apu::AudioSystem->client_callback"); uint64_t args[] = {client_callback_arg}; @@ -148,6 +189,12 @@ void AudioSystem::WorkerThreadMain() { args, xe::countof(args)); } +#if XE_PLATFORM_IOS + if (IsTitleStopRequested(processor_)) { + break; + } +#endif // XE_PLATFORM_IOS + pumped = true; } @@ -181,6 +228,12 @@ void AudioSystem::Initialize() {} void AudioSystem::Shutdown() { worker_running_ = false; shutdown_event_->Set(); +#if XE_PLATFORM_IOS + resume_event_->Set(); + for (size_t i = 0; i < kMaximumClientCount; ++i) { + client_semaphores_[i]->Release(1, nullptr); + } +#endif // XE_PLATFORM_IOS if (worker_thread_) { worker_thread_->Wait(0, 0, 0, nullptr); worker_thread_.reset(); @@ -411,10 +464,9 @@ bool AudioSystem::Restore(ByteStream* stream) { } void AudioSystem::Pause() { - if (paused_) { + if (paused_.exchange(true, std::memory_order_acq_rel)) { return; } - paused_ = true; // Kind of a hack, but it works. shutdown_event_->Set(); @@ -424,10 +476,9 @@ void AudioSystem::Pause() { } void AudioSystem::Resume() { - if (!paused_) { + if (!paused_.exchange(false, std::memory_order_acq_rel)) { return; } - paused_ = false; resume_event_->Set(); diff --git a/src/xenia/apu/audio_system.h b/src/xenia/apu/audio_system.h index 987adac04..420cb7870 100644 --- a/src/xenia/apu/audio_system.h +++ b/src/xenia/apu/audio_system.h @@ -68,7 +68,7 @@ class AudioSystem { bool Save(ByteStream* stream); bool Restore(ByteStream* stream); - bool is_paused() const { return paused_; } + bool is_paused() const { return paused_.load(std::memory_order_acquire); } void Pause(); void Resume(); @@ -116,7 +116,7 @@ class AudioSystem { std::unique_ptr shutdown_event_; xe::threading::WaitHandle* wait_handles_[kMaximumClientCount + 1]; - bool paused_ = false; + std::atomic paused_ = false; threading::Fence pause_fence_; std::unique_ptr resume_event_; }; diff --git a/src/xenia/apu/conversion.h b/src/xenia/apu/conversion.h index a8be90749..1de4ad3c1 100644 --- a/src/xenia/apu/conversion.h +++ b/src/xenia/apu/conversion.h @@ -35,8 +35,8 @@ static void _generic_sequential_6_BE_to_interleaved_6_LE( } } } -#if XE_COMPILER_CLANG_CL != 1 && !XE_PLATFORM_LINUX && !XE_PLATFORM_MAC -// load_be_u32 unavailable on clang-cl, clang on Linux, or clang on macOS +#if XE_COMPILER_CLANG_CL != 1 && !XE_PLATFORM_LINUX && !XE_PLATFORM_APPLE +// load_be_u32 unavailable on clang-cl, clang on Linux, or clang on Apple XE_NOINLINE static void _movbe_sequential_6_BE_to_interleaved_6_LE( float* XE_RESTRICT output, const float* XE_RESTRICT input, diff --git a/src/xenia/apu/sdl/sdl_audio_driver.cc b/src/xenia/apu/sdl/sdl_audio_driver.cc index 5e15cc305..4437791e3 100644 --- a/src/xenia/apu/sdl/sdl_audio_driver.cc +++ b/src/xenia/apu/sdl/sdl_audio_driver.cc @@ -22,6 +22,20 @@ namespace xe { namespace apu { namespace sdl { +namespace { + +bool ShouldReportUnderrun(uint64_t count, uint64_t last_reported_count) { + const uint64_t next_report = + last_reported_count == 0 ? 1 : last_reported_count * 2; + return count >= next_report; +} + +bool ShouldReportAudioStats(uint64_t submitted_count) { + return (submitted_count & 1023) == 0; +} + +} // namespace + SDLAudioDriver::SDLAudioDriver(xe::threading::Semaphore* semaphore, uint32_t frequency, uint32_t channels, bool need_format_conversion) @@ -115,7 +129,57 @@ void SDLAudioDriver::SubmitFrame(float* frame) { { std::unique_lock guard(frames_mutex_); frames_queued_.push(output_frame); + const size_t queue_depth = frames_queued_.size(); + size_t previous_max = max_queue_depth_.load(std::memory_order_relaxed); + while (queue_depth > previous_max && + !max_queue_depth_.compare_exchange_weak(previous_max, queue_depth, + std::memory_order_relaxed, + std::memory_order_relaxed)) { + } + if (queue_depth <= 2) { + low_queue_count_.fetch_add(1, std::memory_order_relaxed); + } } + const uint64_t submitted = + frames_submitted_.fetch_add(1, std::memory_order_relaxed) + 1; + + const uint64_t underruns = underrun_count_.load(std::memory_order_relaxed); + uint64_t last_reported = + last_reported_underrun_count_.load(std::memory_order_relaxed); + if (underruns != 0 && ShouldReportUnderrun(underruns, last_reported) && + last_reported_underrun_count_.compare_exchange_strong( + last_reported, underruns, std::memory_order_relaxed, + std::memory_order_relaxed)) { + XELOGW( + "SDL audio underrun: count={}, submitted={}, played={}, " + "max_queue_depth={}", + underruns, frames_submitted_.load(std::memory_order_relaxed), + frames_played_.load(std::memory_order_relaxed), + max_queue_depth_.load(std::memory_order_relaxed)); + } + +#if XE_PLATFORM_IOS + const uint64_t low_queue = low_queue_count_.load(std::memory_order_relaxed); + uint64_t last_low_queue = + last_reported_low_queue_count_.load(std::memory_order_relaxed); + if (low_queue != 0 && ShouldReportUnderrun(low_queue, last_low_queue) && + last_reported_low_queue_count_.compare_exchange_strong( + last_low_queue, low_queue, std::memory_order_relaxed, + std::memory_order_relaxed)) { + XELOGW( + "SDL audio queue running low: count={}, submitted={}, played={}, " + "underruns={}, max_queue_depth={}", + low_queue, submitted, frames_played_.load(std::memory_order_relaxed), + underruns, max_queue_depth_.load(std::memory_order_relaxed)); + } + if (ShouldReportAudioStats(submitted)) { + XELOGI( + "SDL audio stats: submitted={}, played={}, underruns={}, low_queue={}, " + "max_queue_depth={}", + submitted, frames_played_.load(std::memory_order_relaxed), underruns, + low_queue, max_queue_depth_.load(std::memory_order_relaxed)); + } +#endif // XE_PLATFORM_IOS } void SDLAudioDriver::Pause() { @@ -169,6 +233,10 @@ void SDLAudioDriver::SDLCallback(void* userdata, SDL_AudioStream* stream, { std::unique_lock guard(driver->frames_mutex_); if (driver->frames_queued_.empty()) { + driver->underrun_count_.fetch_add(1, std::memory_order_relaxed); + if (driver->semaphore_) { + driver->semaphore_->Release(1, nullptr); + } return; } buffer = driver->frames_queued_.front(); @@ -213,8 +281,11 @@ void SDLAudioDriver::SDLCallback(void* userdata, SDL_AudioStream* stream, driver->frames_unused_.push(buffer); } - auto ret = driver->semaphore_->Release(1, nullptr); - assert_true(ret); + driver->frames_played_.fetch_add(1, std::memory_order_relaxed); + if (driver->semaphore_) { + auto ret = driver->semaphore_->Release(1, nullptr); + assert_true(ret); + } } } } // namespace sdl diff --git a/src/xenia/apu/sdl/sdl_audio_driver.h b/src/xenia/apu/sdl/sdl_audio_driver.h index 4e74f01b6..36d307450 100644 --- a/src/xenia/apu/sdl/sdl_audio_driver.h +++ b/src/xenia/apu/sdl/sdl_audio_driver.h @@ -10,6 +10,7 @@ #ifndef XENIA_APU_SDL_SDL_AUDIO_DRIVER_H_ #define XENIA_APU_SDL_SDL_AUDIO_DRIVER_H_ +#include #include #include #include @@ -58,6 +59,14 @@ class SDLAudioDriver : public AudioDriver { std::queue frames_queued_ = {}; std::stack frames_unused_ = {}; std::mutex frames_mutex_ = {}; + + std::atomic frames_submitted_{0}; + std::atomic frames_played_{0}; + std::atomic underrun_count_{0}; + std::atomic low_queue_count_{0}; + std::atomic last_reported_underrun_count_{0}; + std::atomic last_reported_low_queue_count_{0}; + std::atomic max_queue_depth_{0}; }; } // namespace sdl diff --git a/src/xenia/apu/sdl/sdl_audio_system.cc b/src/xenia/apu/sdl/sdl_audio_system.cc index fb41bc17a..cb5502bf0 100644 --- a/src/xenia/apu/sdl/sdl_audio_system.cc +++ b/src/xenia/apu/sdl/sdl_audio_system.cc @@ -9,13 +9,46 @@ #include "xenia/apu/sdl/sdl_audio_system.h" +#include + #include "xenia/apu/apu_flags.h" #include "xenia/apu/sdl/sdl_audio_driver.h" +#include "xenia/base/logging.h" +#include "xenia/base/threading.h" namespace xe { namespace apu { namespace sdl { +namespace { + +#if XE_PLATFORM_IOS +// Fallback used when SDL/CoreAudio device creation fails on iOS. +// Keeps guest audio callback flow alive without real audio output. +class SilentAudioDriver final : public AudioDriver { + public: + explicit SilentAudioDriver(xe::threading::Semaphore* semaphore) + : semaphore_(semaphore) {} + + bool Initialize() override { return true; } + void Shutdown() override {} + void SubmitFrame(float* samples) override { + (void)samples; + if (semaphore_) { + semaphore_->Release(1, nullptr); + } + } + void Pause() override {} + void Resume() override {} + void SetVolume(float volume) override { (void)volume; } + + private: + xe::threading::Semaphore* semaphore_ = nullptr; +}; +#endif // XE_PLATFORM_IOS + +} // namespace + std::unique_ptr SDLAudioSystem::Create(cpu::Processor* processor) { return std::make_unique(processor); } @@ -31,10 +64,32 @@ X_STATUS SDLAudioSystem::CreateDriver(size_t index, xe::threading::Semaphore* semaphore, AudioDriver** out_driver) { assert_not_null(out_driver); +#if XE_PLATFORM_IOS + // iOS/SDL audio path is single-output in practice here. Additional guest + // clients should not attempt to open another device because SDL reports + // "Audio device already open" and we fall back to silence anyway. + if (index > 0) { + static std::atomic logged_secondary_fallback{false}; + if (!logged_secondary_fallback.exchange(true, std::memory_order_relaxed)) { + XELOGW( + "SDLAudioSystem: iOS secondary audio clients use silent fallback; " + "keeping primary SDL device on client 0"); + } + *out_driver = new SilentAudioDriver(semaphore); + return X_STATUS_SUCCESS; + } +#endif + auto driver = std::make_unique(semaphore); if (!driver->Initialize()) { driver->Shutdown(); +#if XE_PLATFORM_IOS + XELOGW("SDLAudioSystem: SDL audio init failed, using silent fallback"); + *out_driver = new SilentAudioDriver(semaphore); + return X_STATUS_SUCCESS; +#else return X_STATUS_UNSUCCESSFUL; +#endif } *out_driver = driver.release(); @@ -44,12 +99,29 @@ X_STATUS SDLAudioSystem::CreateDriver(size_t index, AudioDriver* SDLAudioSystem::CreateDriver(xe::threading::Semaphore* semaphore, uint32_t frequency, uint32_t channels, bool need_format_conversion) { +#if XE_PLATFORM_IOS + static std::atomic logged_independent_fallback{false}; + if (!logged_independent_fallback.exchange(true, std::memory_order_relaxed)) { + XELOGW( + "SDLAudioSystem: iOS independent audio drivers use silent fallback; " + "secondary SDL/CoreAudio devices are not opened"); + } + return new SilentAudioDriver(semaphore); +#else return new SDLAudioDriver(semaphore, frequency, channels, need_format_conversion); +#endif // XE_PLATFORM_IOS } void SDLAudioSystem::DestroyDriver(AudioDriver* driver) { assert_not_null(driver); +#if XE_PLATFORM_IOS + if (auto* silent_driver = dynamic_cast(driver)) { + silent_driver->Shutdown(); + delete silent_driver; + return; + } +#endif auto sdldriver = dynamic_cast(driver); assert_not_null(sdldriver); sdldriver->Shutdown(); diff --git a/src/xenia/base/CMakeLists.txt b/src/xenia/base/CMakeLists.txt index f596a5519..49f3b1426 100644 --- a/src/xenia/base/CMakeLists.txt +++ b/src/xenia/base/CMakeLists.txt @@ -10,6 +10,12 @@ if(_base_exclude AND _base_srcs) list(REMOVE_ITEM _base_srcs ${_base_exclude}) set_target_properties(xenia-base PROPERTIES SOURCES "${_base_srcs}") endif() +if(XE_PLATFORM_IOS) + # This file is named for the original macOS implementation, but the + # Objective-C autorelease pool runtime is Apple-wide. + target_sources(xenia-base PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/autorelease_pool_mac.cc) +endif() target_link_libraries(xenia-base PUBLIC fmt) target_link_libraries(xenia-base PRIVATE zlib-ng boost_context) xe_target_defaults(xenia-base) diff --git a/src/xenia/base/autorelease_pool_mac.cc b/src/xenia/base/autorelease_pool_mac.cc index 4560d20a8..7633ac19b 100644 --- a/src/xenia/base/autorelease_pool_mac.cc +++ b/src/xenia/base/autorelease_pool_mac.cc @@ -9,7 +9,7 @@ #include "xenia/base/autorelease_pool_mac.h" -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include #include @@ -174,4 +174,4 @@ ScopedAutoreleasePool::~ScopedAutoreleasePool() { } // namespace xe -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE diff --git a/src/xenia/base/autorelease_pool_mac.h b/src/xenia/base/autorelease_pool_mac.h index da8adb67e..f0129b462 100644 --- a/src/xenia/base/autorelease_pool_mac.h +++ b/src/xenia/base/autorelease_pool_mac.h @@ -16,7 +16,7 @@ #include "xenia/base/platform.h" -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE namespace xe { @@ -94,13 +94,13 @@ class ScopedAutoreleasePool { #define XE_SCOPED_AUTORELEASE_POOL(name) \ xe::ScopedAutoreleasePool _autorelease_pool_##__LINE__(name) -#else // !XE_PLATFORM_MAC +#else // !XE_PLATFORM_APPLE #define XE_AUTORELEASE_POOL_PUSH(location) nullptr #define XE_AUTORELEASE_POOL_POP(pool, location) (void)0 #define XE_AUTORELEASE_POOL_CHECK_LEAKS() (void)0 #define XE_SCOPED_AUTORELEASE_POOL(name) (void)0 -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #endif // XENIA_BASE_AUTORELEASE_POOL_MAC_H_ diff --git a/src/xenia/base/cache_control_ios.cc b/src/xenia/base/cache_control_ios.cc new file mode 100644 index 000000000..b9c6cf8d7 --- /dev/null +++ b/src/xenia/base/cache_control_ios.cc @@ -0,0 +1,23 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include +#include + +#include + +extern "C" void __clear_cache(void* start, void* end) { + auto* start_bytes = static_cast(start); + auto* end_bytes = static_cast(end); + if (end_bytes <= start_bytes) { + return; + } + + sys_icache_invalidate(start, static_cast(end_bytes - start_bytes)); +} diff --git a/src/xenia/base/exception_handler_posix.cc b/src/xenia/base/exception_handler_posix.cc index a91a8765b..278fa1ec3 100644 --- a/src/xenia/base/exception_handler_posix.cc +++ b/src/xenia/base/exception_handler_posix.cc @@ -39,7 +39,7 @@ std::pair handlers_[kMaxHandlerCount]; static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, void* signal_context) { -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_APPLE && XE_ARCH_ARM64 // The Darwin kernel may pass an unaligned ucontext_t pointer to signal // handlers; copy into an aligned local before reading. mcontext_t is a // pointer on Mac, so writes still reach kernel storage via the pointer. @@ -54,7 +54,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, HostThreadContext thread_context; #if XE_ARCH_AMD64 -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Darwin: mcontext is a pointer; integer state in __ss, FP/XMM in __fs. // __fpu_xmm0..__fpu_xmm15 are laid out contiguously in // __darwin_x86_float_state64. @@ -101,9 +101,9 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, thread_context.r15 = uint64_t(mcontext.gregs[REG_R15]); std::memcpy(thread_context.xmm_registers, mcontext.fpregs->_xmm, sizeof(thread_context.xmm_registers)); -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #elif XE_ARCH_ARM64 -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Darwin: mcontext is a pointer, registers in __ss and __ns. for (int i = 0; i < 29; ++i) { thread_context.x[i] = mcontext->__ss.__x[i]; @@ -150,7 +150,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, std::memcpy(thread_context.v, mcontext_fpsimd->vregs, sizeof(thread_context.v)); } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #endif // XE_ARCH Exception ex; @@ -164,7 +164,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, #if XE_ARCH_AMD64 // x86_pf_error_code::X86_PF_WRITE constexpr uint64_t kX86PageFaultErrorCodeWrite = UINT64_C(1) << 1; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE access_violation_operation = (uint64_t(mcontext->__es.__err) & kX86PageFaultErrorCodeWrite) ? Exception::AccessViolationOperation::kWrite @@ -176,7 +176,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, : Exception::AccessViolationOperation::kRead; #endif #elif XE_ARCH_ARM64 -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE { // On Darwin, determine access direction from the faulting instruction. uint64_t fault_pc = mcontext->__ss.__pc; @@ -242,7 +242,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, Exception::AccessViolationOperation::kUnknown; } } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #else access_violation_operation = Exception::AccessViolationOperation::kUnknown; @@ -260,7 +260,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, // Exception handled. #if XE_ARCH_AMD64 uint32_t modified_register_index; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE mcontext->__ss.__rip = thread_context.rip; mcontext->__ss.__rflags = thread_context.eflags; // Pointer-to-member map; order must match X64Register. @@ -327,10 +327,10 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, &thread_context.xmm_registers[modified_register_index], sizeof(vec128_t)); } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #elif XE_ARCH_ARM64 uint32_t modified_register_index; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE uint32_t modified_x_registers_remaining = ex.modified_x_registers(); while (xe::bit_scan_forward(modified_x_registers_remaining, &modified_register_index)) { @@ -386,7 +386,7 @@ static void ExceptionHandlerCallback(int signal_number, siginfo_t* signal_info, thread_context.x[modified_register_index]; } } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #endif // XE_ARCH return; } diff --git a/src/xenia/base/filesystem_posix.cc b/src/xenia/base/filesystem_posix.cc index 4a6963d8f..d7952505c 100644 --- a/src/xenia/base/filesystem_posix.cc +++ b/src/xenia/base/filesystem_posix.cc @@ -23,7 +23,7 @@ #include #include #include -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include #include #endif @@ -47,7 +47,7 @@ std::filesystem::path to_path(const std::u16string_view source) { namespace filesystem { std::filesystem::path GetExecutablePath() { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE char path[PATH_MAX]; uint32_t size = sizeof(path); if (_NSGetExecutablePath(path, &size) == 0) { diff --git a/src/xenia/base/memory_posix.cc b/src/xenia/base/memory_posix.cc index 93c89ac89..269022e11 100644 --- a/src/xenia/base/memory_posix.cc +++ b/src/xenia/base/memory_posix.cc @@ -24,11 +24,13 @@ #include #include -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include +#if XE_PLATFORM_MAC #include +#endif #include -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #include "xenia/base/logging.h" #include "xenia/base/math.h" @@ -112,8 +114,13 @@ PageAccess ToXeniaProtectFlags(const char* protection) { return PageAccess::kNoAccess; } -#if XE_PLATFORM_MAC bool IsWritableExecutableMemorySupported() { +#if XE_PLATFORM_APPLE +#if XE_PLATFORM_IOS + // iOS app builds don't have MAP_JIT entitlement in this project setup. + // Use the split execute/write mapping path instead. + return false; +#else // macOS allows RWX only on anonymous MAP_JIT regions. Callers that see // true must allocate via AllocFixed (which sets MAP_JIT) and toggle // pthread_jit_write_protect_np around writes. MAP_JIT requires the @@ -136,10 +143,11 @@ bool IsWritableExecutableMemorySupported() { return true; }(); return supported; -} +#endif // XE_PLATFORM_IOS #else -bool IsWritableExecutableMemorySupported() { return true; } -#endif // XE_PLATFORM_MAC + return true; +#endif // XE_PLATFORM_APPLE +} struct MappedFileRange { uintptr_t region_begin; @@ -178,32 +186,40 @@ void* AllocFixed(void* base_address, size_t length, uint32_t prot = ToPosixProtectFlags(access); int flags = MAP_PRIVATE | MAP_ANONYMOUS; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE if (access == PageAccess::kExecuteReadWrite || access == PageAccess::kExecuteReadOnly) { +#if !XE_PLATFORM_IOS +#ifdef MAP_JIT flags |= MAP_JIT; +#endif +#endif } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE if (base_address != nullptr) { if (allocation_type == AllocationType::kCommit) { -#if XE_PLATFORM_MAC - size_t host_page = page_size(); - uintptr_t aligned_addr = - reinterpret_cast(base_address) & ~(host_page - 1); - uintptr_t end_addr = reinterpret_cast(base_address) + length; - end_addr = (end_addr + host_page - 1) & ~(host_page - 1); - if (mprotect(reinterpret_cast(aligned_addr), - end_addr - aligned_addr, prot) == 0) { +#if XE_PLATFORM_APPLE + size_t host_page_size = page_size(); + uintptr_t aligned_start = + reinterpret_cast(base_address) & ~(host_page_size - 1); + uintptr_t aligned_end = xe::align( + reinterpret_cast(base_address) + length, host_page_size); + size_t aligned_length = + aligned_end > aligned_start ? aligned_end - aligned_start : 0; + if (!aligned_length) { return base_address; } - return nullptr; + return mprotect(reinterpret_cast(aligned_start), aligned_length, + prot) == 0 + ? base_address + : nullptr; #else if (Protect(base_address, length, access)) { return base_address; } return nullptr; -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE } #ifdef MAP_FIXED_NOREPLACE flags |= MAP_FIXED_NOREPLACE; @@ -272,39 +288,57 @@ bool Protect(void* base_address, size_t length, PageAccess access, } bool QueryProtect(void* base_address, size_t& length, PageAccess& access_out) { +#if XE_PLATFORM_APPLE + access_out = PageAccess::kNoAccess; #if XE_PLATFORM_MAC - mach_vm_address_t address = reinterpret_cast(base_address); + mach_vm_address_t address = + static_cast(reinterpret_cast(base_address)); mach_vm_size_t region_size = 0; +#else + vm_address_t address = + static_cast(reinterpret_cast(base_address)); + vm_size_t region_size = 0; +#endif vm_region_basic_info_data_64_t info; mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64; - mach_port_t object_name; + mach_port_t object_name = MACH_PORT_NULL; +#if XE_PLATFORM_MAC kern_return_t kr = mach_vm_region( mach_task_self(), &address, ®ion_size, VM_REGION_BASIC_INFO_64, reinterpret_cast(&info), &info_count, &object_name); +#else + kern_return_t kr = vm_region_64( + mach_task_self(), &address, ®ion_size, VM_REGION_BASIC_INFO_64, + reinterpret_cast(&info), &info_count, &object_name); +#endif + + if (object_name != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object_name); + } if (kr != KERN_SUCCESS) { return false; } - if (address > reinterpret_cast(base_address)) { + auto base = reinterpret_cast(base_address); + if (address > base) { return false; } - length = - static_cast((address + region_size) - - reinterpret_cast(base_address)); + length = static_cast((address + region_size) - base); - if ((info.protection & (VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE)) == - (VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE)) { + const vm_prot_t prot = info.protection; + const bool can_read = (prot & VM_PROT_READ) != 0; + const bool can_write = (prot & VM_PROT_WRITE) != 0; + const bool can_execute = (prot & VM_PROT_EXECUTE) != 0; + if (can_read && can_write && can_execute) { access_out = PageAccess::kExecuteReadWrite; - } else if ((info.protection & (VM_PROT_READ | VM_PROT_EXECUTE)) == - (VM_PROT_READ | VM_PROT_EXECUTE)) { + } else if (can_read && can_execute) { access_out = PageAccess::kExecuteReadOnly; - } else if ((info.protection & (VM_PROT_READ | VM_PROT_WRITE)) == - (VM_PROT_READ | VM_PROT_WRITE)) { + } else if (can_read && can_write) { access_out = PageAccess::kReadWrite; - } else if (info.protection & VM_PROT_READ) { + } else if (can_read) { access_out = PageAccess::kReadOnly; } else { access_out = PageAccess::kNoAccess; @@ -357,7 +391,7 @@ bool QueryProtect(void* base_address, size_t& length, PageAccess& access_out) { memory_maps.close(); return false; -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE } FileMappingHandle CreateFileMappingHandle(const std::filesystem::path& path, @@ -405,7 +439,41 @@ FileMappingHandle CreateFileMappingHandle(const std::filesystem::path& path, } oflag |= O_CREAT; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_IOS + // iOS app sandboxing may reject POSIX shared memory namespaces, so use an + // unlinked temporary file-backed mapping instead. + const char* tmpdir_env = std::getenv("TMPDIR"); + if (!tmpdir_env || !tmpdir_env[0]) { + XELOGE("TMPDIR is unavailable for iOS file mapping fallback."); + return kFileMappingHandleInvalid; + } + std::string backing_path(tmpdir_env); + if (backing_path.back() != '/') { + backing_path.push_back('/'); + } + backing_path += path.filename().string(); + int open_flags = oflag; + if (open_flags & O_RDWR) { + open_flags |= O_TRUNC; + } + int ret = open(backing_path.c_str(), open_flags, 0600); + if (ret < 0) { + XELOGE("open({}) failed: {} ({})", backing_path, strerror(errno), errno); + return kFileMappingHandleInvalid; + } + if (ftruncate(ret, length) < 0) { + XELOGE("ftruncate({}, 0x{:X}) failed: {} ({})", backing_path, length, + strerror(errno), errno); + close(ret); + unlink(backing_path.c_str()); + return kFileMappingHandleInvalid; + } + if (unlink(backing_path.c_str()) != 0) { + XELOGW("unlink({}) failed: {} ({})", backing_path, strerror(errno), errno); + } + return ret; +#else +#if XE_PLATFORM_APPLE std::string shm_name = "/" + path.filename().string(); if (shm_name.size() > 30) { std::size_t h = std::hash{}(shm_name); @@ -454,7 +522,8 @@ FileMappingHandle CreateFileMappingHandle(const std::filesystem::path& path, } InstallCleanupHandlers(); return ret; -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE +#endif // XE_PLATFORM_IOS #endif } @@ -462,7 +531,11 @@ void CloseFileMappingHandle(FileMappingHandle handle, const std::filesystem::path& path) { close(handle); #if !XE_PLATFORM_ANDROID -#if XE_PLATFORM_MAC +#if XE_PLATFORM_IOS + // iOS uses already-unlinked temporary file-backed mappings. + return; +#endif +#if XE_PLATFORM_APPLE std::string shm_name = "/" + path.filename().string(); if (shm_name.size() > 30) { std::size_t h = std::hash{}(shm_name); @@ -492,7 +565,7 @@ void CloseFileMappingHandle(FileMappingHandle handle, g_shm_file_names.erase(it); } } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #endif } @@ -531,7 +604,7 @@ bool UnmapFileView(FileMappingHandle handle, void* base_address, size_t length) { std::lock_guard guard(g_mapped_file_ranges_mutex); -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE uintptr_t unmap_begin = reinterpret_cast(base_address); uintptr_t unmap_end = unmap_begin + length; @@ -570,7 +643,7 @@ bool UnmapFileView(FileMappingHandle handle, void* base_address, // TODO: Implement partial file unmapping. assert_always("Error: Partial unmapping of files not yet supported."); return munmap(base_address, length) == 0; -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE } } // namespace memory diff --git a/src/xenia/base/platform.h b/src/xenia/base/platform.h index 2b35ae5b5..30c0ba088 100644 --- a/src/xenia/base/platform.h +++ b/src/xenia/base/platform.h @@ -27,8 +27,16 @@ #include #endif -#if defined(TARGET_OS_MAC) && TARGET_OS_MAC +#if defined(__APPLE__) +#define XE_PLATFORM_APPLE 1 +#if (defined(TARGET_OS_IOS) && TARGET_OS_IOS) || \ + (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE) +#define XE_PLATFORM_IOS 1 +#elif defined(TARGET_OS_MAC) && TARGET_OS_MAC #define XE_PLATFORM_MAC 1 +#else +#error Unsupported Apple target OS. +#endif #elif defined(WIN32) || defined(_WIN32) #define XE_PLATFORM_WIN32 1 #elif defined(__ANDROID__) @@ -95,9 +103,9 @@ #include #endif // XE_PLATFORM_WIN32 -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE #if XE_COMPILER_MSVC #define _XEPACKEDSCOPE(body) __pragma(pack(push, 1)) body __pragma(pack(pop)); diff --git a/src/xenia/base/system_ios.cc b/src/xenia/base/system_ios.cc new file mode 100644 index 000000000..161576822 --- /dev/null +++ b/src/xenia/base/system_ios.cc @@ -0,0 +1,36 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/base/system.h" + +#include "xenia/base/logging.h" + +namespace xe { + +void LaunchWebBrowser(const std::string_view url) { + XELOGW("LaunchWebBrowser is not supported on iOS: {}", url); +} + +void LaunchFileExplorer(const std::filesystem::path& path) { + XELOGW("LaunchFileExplorer is not supported on iOS: {}", path.string()); +} + +bool SetProcessPriorityClass(const uint32_t priority_class) { + static_cast(priority_class); + return true; +} + +bool IsUseNexusForGameBarEnabled() { return false; } + +void ShowSimpleMessageBox(SimpleMessageBoxType type, std::string_view message) { + static_cast(type); + XELOGW("ShowSimpleMessageBox is not supported on iOS: {}", message); +} + +} // namespace xe diff --git a/src/xenia/base/system_mac.cc b/src/xenia/base/system_mac.cc index 92081798e..b398f9ef1 100644 --- a/src/xenia/base/system_mac.cc +++ b/src/xenia/base/system_mac.cc @@ -9,23 +9,36 @@ #include +#include "xenia/base/logging.h" +#include "xenia/base/platform.h" #include "xenia/base/system.h" namespace xe { void LaunchWebBrowser(const std::string_view url) { +#if XE_PLATFORM_IOS + XELOGW("LaunchWebBrowser is not supported on iOS: {}", url); +#else auto cmd = std::string("open "); cmd.append(url); system(cmd.c_str()); +#endif } void LaunchFileExplorer(const std::filesystem::path& path) { +#if XE_PLATFORM_IOS + XELOGW("LaunchFileExplorer is not supported on iOS: {}", path.string()); +#else auto cmd = std::string("open "); cmd.append(path.string()); system(cmd.c_str()); +#endif } void ShowSimpleMessageBox(SimpleMessageBoxType type, std::string_view message) { +#if XE_PLATFORM_IOS + XELOGW("ShowSimpleMessageBox is not supported on iOS: {}", message); +#else const char* icon; switch (type) { case SimpleMessageBoxType::Help: @@ -55,6 +68,7 @@ void ShowSimpleMessageBox(SimpleMessageBoxType type, std::string_view message) { cmd += icon; cmd += " buttons {\"OK\"} default button \"OK\" with title \"Xenia\"'"; system(cmd.c_str()); +#endif } bool SetProcessPriorityClass(const uint32_t priority_class) { return true; } diff --git a/src/xenia/base/threading.h b/src/xenia/base/threading.h index e50b069de..65fa65218 100644 --- a/src/xenia/base/threading.h +++ b/src/xenia/base/threading.h @@ -107,6 +107,18 @@ void set_current_thread_id(uint32_t id); // Sets the current thread name. void set_name(const std::string_view name); +#if XE_PLATFORM_IOS +enum class ThreadQoS { + kDefault, + kUtility, + kUserInitiated, + kUserInteractive, +}; + +// Sets the current thread's Darwin QoS class on iOS. +bool set_current_thread_qos(ThreadQoS qos); +#endif // XE_PLATFORM_IOS + // Yields the current thread to the scheduler. Maybe. void MaybeYield(); diff --git a/src/xenia/base/threading_posix.cc b/src/xenia/base/threading_posix.cc index e3a5c2baa..49114c485 100644 --- a/src/xenia/base/threading_posix.cc +++ b/src/xenia/base/threading_posix.cc @@ -33,11 +33,15 @@ #include "xenia/base/logging.h" -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include #include #endif +#if XE_PLATFORM_IOS +#include +#endif + #if XE_PLATFORM_LINUX #include #endif @@ -65,6 +69,26 @@ namespace xe { namespace threading { +#if XE_PLATFORM_IOS +namespace { + +qos_class_t ToDarwinQos(ThreadQoS qos) { + switch (qos) { + case ThreadQoS::kDefault: + return QOS_CLASS_DEFAULT; + case ThreadQoS::kUtility: + return QOS_CLASS_UTILITY; + case ThreadQoS::kUserInitiated: + return QOS_CLASS_USER_INITIATED; + case ThreadQoS::kUserInteractive: + return QOS_CLASS_USER_INTERACTIVE; + } + return QOS_CLASS_DEFAULT; +} + +} // namespace +#endif // XE_PLATFORM_IOS + #if XE_PLATFORM_ANDROID // May be null if no dynamically loaded functions are required. static void* android_libc_; @@ -118,8 +142,8 @@ enum class SignalType { k_Count }; -#if XE_PLATFORM_MAC -// macOS lacks real-time signals (SIGRTMIN/SIGRTMAX). Use SIGUSR1/SIGUSR2. +#if XE_PLATFORM_APPLE +// Darwin lacks real-time signals (SIGRTMIN/SIGRTMAX). Use SIGUSR1/SIGUSR2. int GetSystemSignal(SignalType num) { switch (num) { case SignalType::kThreadSuspend: @@ -181,7 +205,7 @@ void EnableAffinityConfiguration() {} // uint64_t ticks() { return mach_absolute_time(); } uint32_t current_thread_system_id() { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE return static_cast(pthread_mach_thread_np(pthread_self())); #else return static_cast(syscall(SYS_gettid)); @@ -212,7 +236,7 @@ void Sleep(std::chrono::microseconds duration) { void NanoSleep(int64_t duration) { Sleep(std::chrono::nanoseconds(duration)); } void NanoSleepPrecise(int64_t ns) { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Darwin's nanosleep can oversleep by 100-500us under load. Land precisely // on the deadline by using mach_wait_until for the bulk of the wait and // busy-waiting the last ~200us. @@ -271,7 +295,7 @@ bool SetTlsValue(TlsHandle handle, uintptr_t value) { class PosixConditionBase { public: PosixConditionBase() { -#if !XE_PLATFORM_MAC +#if !XE_PLATFORM_APPLE // Initialize as robust mutex to handle thread termination gracefully. pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); @@ -291,8 +315,8 @@ class PosixConditionBase { WaitResult Wait(std::chrono::milliseconds timeout) { bool executed; auto predicate = [this] { return this->signaled(); }; -#if XE_PLATFORM_MAC - // Standard locking on macOS (no robust mutex support). +#if XE_PLATFORM_APPLE + // Standard locking on Apple platforms (no robust mutex support). std::unique_lock lock(mutex_); #else // Handle robust mutex locking. @@ -353,8 +377,8 @@ class PosixConditionBase { bool all_locked = true; for (size_t i = 0; i < handles.size(); ++i) { -#if XE_PLATFORM_MAC - // macOS: no robust mutex support. +#if XE_PLATFORM_APPLE + // Apple platforms: no robust mutex support. std::unique_lock lk(handles[i]->mutex_, std::try_to_lock); if (!lk.owns_lock()) { all_locked = false; @@ -467,7 +491,13 @@ class PosixCondition : public PosixConditionBase { bool Signal() override { auto lock = std::unique_lock(mutex_); signal_ = true; - cond_.notify_all(); + // Manual-reset events must release all waiters while signaled. Auto-reset + // events consume one signal, so waking one waiter avoids broadcast churn. + if (manual_reset_) { + cond_.notify_all(); + } else { + cond_.notify_one(); + } return true; } @@ -687,9 +717,17 @@ class PosixCondition final : public PosixConditionBase { } } +#if XE_PLATFORM_IOS + // Xenia uses raw pthreads, which Xcode reports as QoS Unavailable unless + // a Darwin QoS class is attached. Use Default here so ordinary Xenia + // threads get normal scheduler treatment and higher-priority iOS paths + // can opt in explicitly from their own entrypoints. + (void)pthread_attr_set_qos_class_np(&attr, QOS_CLASS_DEFAULT, 0); +#endif + if (params.initial_priority != 0) { sched_param sched{}; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Remap into Darwin's SCHED_FIFO range (see set_priority). static const int fifo_min = sched_get_priority_min(SCHED_FIFO); static const int fifo_max = sched_get_priority_max(SCHED_FIFO); @@ -833,8 +871,8 @@ class PosixCondition final : public PosixConditionBase { WaitStarted(); std::unique_lock lock(state_mutex_); if (state_ != State::kUninitialized && state_ != State::kFinished) { -#if XE_PLATFORM_MAC - // macOS can only set the current thread's name. +#if XE_PLATFORM_APPLE + // Darwin can only set the current thread's name. if (pthread_self() == thread_) { pthread_setname_np(std::string(name).c_str()); } @@ -859,7 +897,7 @@ class PosixCondition final : public PosixConditionBase { #endif uint32_t system_id() const { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE return static_cast(pthread_mach_thread_np(thread_)); #else return static_cast(thread_); @@ -868,8 +906,8 @@ class PosixCondition final : public PosixConditionBase { uint64_t affinity_mask() const { WaitStarted(); -#if XE_PLATFORM_MAC - // Thread affinity is not supported on macOS. +#if XE_PLATFORM_APPLE + // Thread affinity is not supported on Apple platforms. return 0; #else cpu_set_t cpu_set; @@ -895,8 +933,8 @@ class PosixCondition final : public PosixConditionBase { void set_affinity_mask(uint64_t mask) const { WaitStarted(); -#if XE_PLATFORM_MAC - // Thread affinity is not supported on macOS. +#if XE_PLATFORM_APPLE + // Thread affinity is not supported on Apple platforms. (void)mask; return; #else @@ -936,7 +974,7 @@ class PosixCondition final : public PosixConditionBase { if (ret != 0) { return -1; } -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Reverse the mapping applied in set_priority so callers see xenia-space // values 1..32 regardless of Darwin's SCHED_FIFO range (typically 15..47). static const int fifo_min = sched_get_priority_min(SCHED_FIFO); @@ -957,7 +995,7 @@ class PosixCondition final : public PosixConditionBase { if (!fifo_failed_) { #endif sched_param param{}; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Xenia's POSIX ThreadPriority tiers are 1/8/16/24/32. Darwin's // SCHED_FIFO range is typically 15..47, so linearly remap xenia 1..32 // into that range to keep all five tiers distinct and monotonically @@ -1005,8 +1043,8 @@ class PosixCondition final : public PosixConditionBase { WaitStarted(); std::unique_lock lock(callback_mutex_); user_callback_ = std::move(callback); -#if XE_PLATFORM_MAC - // No pthread_sigqueue on macOS, use pthread_kill (no si_value payload). +#if XE_PLATFORM_APPLE + // No pthread_sigqueue on Apple platforms; use pthread_kill without payload. pthread_kill(thread_, GetSystemSignal(SignalType::kThreadUserCallback)); #elif XE_PLATFORM_ANDROID sigval value{}; @@ -1120,7 +1158,7 @@ class PosixCondition final : public PosixConditionBase { cond_.notify_all(); } if (is_current_thread) { -#if XE_PLATFORM_MAC && defined(__aarch64__) +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && defined(__aarch64__) pthread_jit_write_protect_np(1); #endif pthread_exit(reinterpret_cast(exit_code)); @@ -1508,6 +1546,9 @@ void* PosixCondition::ThreadStartRoutine(void* parameter) { } #endif threading::set_name(""); +#if XE_PLATFORM_IOS + threading::set_current_thread_qos(ThreadQoS::kDefault); +#endif auto start_data = static_cast(parameter); assert_not_null(start_data); @@ -1605,7 +1646,7 @@ void Thread::Exit(int exit_code) { current_thread_->Terminate(exit_code); } else { // Should only happen with the main thread -#if XE_PLATFORM_MAC && defined(__aarch64__) +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && defined(__aarch64__) pthread_jit_write_protect_np(1); #endif pthread_exit(reinterpret_cast(exit_code)); @@ -1615,7 +1656,7 @@ void Thread::Exit(int exit_code) { } void set_name(const std::string_view name) { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE pthread_setname_np(std::string(name).c_str()); #else pthread_setname_np(pthread_self(), std::string(name).c_str()); @@ -1627,6 +1668,12 @@ void set_name(const std::string_view name) { #endif } +#if XE_PLATFORM_IOS +bool set_current_thread_qos(ThreadQoS qos) { + return pthread_set_qos_class_self_np(ToDarwinQos(qos), 0) == 0; +} +#endif // XE_PLATFORM_IOS + static void signal_handler(int signal, siginfo_t* info, void* /*context*/) { switch (GetSystemSignalType(signal)) { case SignalType::kThreadSuspend: { @@ -1638,8 +1685,8 @@ static void signal_handler(int signal, siginfo_t* info, void* /*context*/) { current_thread_->WaitSuspended(); } break; case SignalType::kThreadUserCallback: { -#if XE_PLATFORM_MAC - // macOS: no si_value payload when using pthread_kill. +#if XE_PLATFORM_APPLE + // Apple platforms: no si_value payload when using pthread_kill. if (alertable_state_ && current_thread_) { auto& condition = static_cast&>(current_thread_->condition()); diff --git a/src/xenia/cpu/backend/a64/a64_backend.cc b/src/xenia/cpu/backend/a64/a64_backend.cc index 9e47660ca..4c7a1bc01 100644 --- a/src/xenia/cpu/backend/a64/a64_backend.cc +++ b/src/xenia/cpu/backend/a64/a64_backend.cc @@ -25,7 +25,7 @@ #if XE_ARCH_ARM64 && XE_COMPILER_MSVC #include #endif -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS #include #endif #include "xenia/cpu/backend/a64/a64_assembler.h" @@ -452,9 +452,8 @@ void* A64HelperEmitter::EmitGuestAndHostSynchronizeStackHelper() { current_stackpoint_depth)))); // w13 = target depth computed by ResolveFunction. - ldr(w13, ptr(x19, static_cast( - offsetof(A64BackendContext, - pending_stackpoint_sync_depth)))); + ldr(w13, ptr(x19, static_cast(offsetof( + A64BackendContext, pending_stackpoint_sync_depth)))); auto& underflow = NewCachedLabel(); cbz(x10, underflow); @@ -484,9 +483,8 @@ void* A64HelperEmitter::EmitGuestAndHostSynchronizeStackHelper() { str(w13, ptr(x19, static_cast(offsetof(A64BackendContext, current_stackpoint_depth)))); mov(w15, 0); - str(w15, ptr(x19, static_cast( - offsetof(A64BackendContext, - pending_stackpoint_sync_depth)))); + str(w15, ptr(x19, static_cast(offsetof( + A64BackendContext, pending_stackpoint_sync_depth)))); // Jump back to the caller. br(x8); @@ -752,6 +750,15 @@ static void BuildGuestTrampoline(uint8_t* buf, void* proc, void* userdata1, A64Backend::A64Backend() { code_cache_ = A64CodeCache::Create(); +#if XE_PLATFORM_IOS + // iOS's JIT entitlement permits writable/executable mappings in place. + // Flipping anonymous pages from RW back to RX with mprotect is rejected + // under TXM and produces launch-time failures. + const bool wx_trampolines = true; +#else + const bool wx_trampolines = memory::IsWritableExecutableMemoryPreferred(); +#endif + // Prefer a sub-2GB slot so fast indirection (rel32) is usable; fall back // to an OS-chosen address if none is available. macOS rejects fixed // PROT_EXEC mappings in this range, so skip the scan entirely there. @@ -778,6 +785,7 @@ A64Backend::A64Backend() { xenia_assert(buf); guest_trampoline_memory_ = reinterpret_cast(buf); guest_trampolines_sub4gb_ = reinterpret_cast(buf) < 0x100000000ull; + guest_trampolines_need_write_protect_ = !wx_trampolines; guest_trampoline_address_bitmap_.Resize(MAX_GUEST_TRAMPOLINES); } @@ -981,13 +989,31 @@ uint32_t A64Backend::CreateGuestTrampoline(GuestTrampolineProc proc, uint8_t* write_pos = &guest_trampoline_memory_[kGuestTrampolineSize * new_index]; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS pthread_jit_write_protect_np(0); #endif + void* protected_page_base = nullptr; + size_t protected_page_size = 0; + if (guest_trampolines_need_write_protect_) { + const size_t page_size = xe::memory::page_size(); + const uintptr_t page_start = + reinterpret_cast(write_pos) & ~(page_size - 1); + const uintptr_t page_end = (reinterpret_cast(write_pos) + + kGuestTrampolineSize + page_size - 1) & + ~(page_size - 1); + protected_page_base = reinterpret_cast(page_start); + protected_page_size = page_end - page_start; + xenia_assert(memory::Protect(protected_page_base, protected_page_size, + xe::memory::PageAccess::kReadWrite)); + } BuildGuestTrampoline(write_pos, reinterpret_cast(proc), userdata1, userdata2, reinterpret_cast(guest_to_host_thunk_)); -#if XE_PLATFORM_MAC + if (guest_trampolines_need_write_protect_) { + xenia_assert(memory::Protect(protected_page_base, protected_page_size, + xe::memory::PageAccess::kExecuteReadOnly)); + } +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS pthread_jit_write_protect_np(1); #endif diff --git a/src/xenia/cpu/backend/a64/a64_backend.h b/src/xenia/cpu/backend/a64/a64_backend.h index c047a45a2..07d4fa310 100644 --- a/src/xenia/cpu/backend/a64/a64_backend.h +++ b/src/xenia/cpu/backend/a64/a64_backend.h @@ -183,6 +183,7 @@ class A64Backend : public Backend { BitMap guest_trampoline_address_bitmap_; uint8_t* guest_trampoline_memory_ = nullptr; bool guest_trampolines_sub4gb_ = false; + bool guest_trampolines_need_write_protect_ = false; }; } // namespace a64 diff --git a/src/xenia/cpu/backend/a64/a64_code_cache_posix.cc b/src/xenia/cpu/backend/a64/a64_code_cache_posix.cc index bf241da14..8f677fb51 100644 --- a/src/xenia/cpu/backend/a64/a64_code_cache_posix.cc +++ b/src/xenia/cpu/backend/a64/a64_code_cache_posix.cc @@ -21,7 +21,7 @@ // libgcc/libunwind APIs for registering DWARF .eh_frame unwind info. // libgcc takes a pointer to a [CIE | FDEs | terminator] section and walks it. // Apple/LLVM libunwind takes a single FDE pointer and must be called once -// per FDE, so on XE_PLATFORM_MAC we walk the buffer ourselves. +// per FDE, so on Apple platforms we walk the buffer ourselves. extern "C" void __register_frame(void*); extern "C" void __deregister_frame(void*); @@ -168,7 +168,7 @@ void PosixA64CodeCache::PlaceCode(uint32_t guest_address, void* machine_code, uint8_t* unwind_execute_address = unwind_reservation.entry_address - generated_code_write_base_ + generated_code_execute_base_; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE // Walk [CIE | FDE | terminator] and register each FDE individually. const uint8_t* p = unwind_reservation.entry_address; uint8_t* p_execute = unwind_execute_address; diff --git a/src/xenia/cpu/backend/a64/a64_emitter.cc b/src/xenia/cpu/backend/a64/a64_emitter.cc index 1607e5cec..6908c7aaa 100644 --- a/src/xenia/cpu/backend/a64/a64_emitter.cc +++ b/src/xenia/cpu/backend/a64/a64_emitter.cc @@ -26,10 +26,33 @@ #include "xenia/cpu/hir/label.h" #include "xenia/cpu/ppc/ppc_context.h" #include "xenia/cpu/processor.h" +#if XE_PLATFORM_IOS +#include "xenia/kernel/xthread.h" +#endif // XE_PLATFORM_IOS DECLARE_int64(a64_max_stackpoints); DECLARE_bool(a64_enable_host_guest_stack_synchronization); +namespace { + +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 +void ExitCurrentGuestThreadForTitleStopIOS(xe::cpu::ppc::PPCContext* context) { + if (!context || !context->processor || + !context->processor->title_stop_requested_ios() || + !xe::kernel::XThread::IsInThread()) { + return; + } + + auto* thread = xe::kernel::XThread::GetCurrentThread(); + if (!thread->is_guest_thread()) { + return; + } + thread->Exit(0); +} +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 + +} // namespace + namespace xe { namespace cpu { namespace backend { @@ -197,6 +220,10 @@ bool A64Emitter::Emit(hir::HIRBuilder* builder, EmitFunctionInfo& func_info) { label = label->next; } +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + EmitTitleStopPollIOS(); +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 + // Process each instruction in the block. const hir::Instr* instr = block->instr_head; while (instr) { @@ -272,6 +299,27 @@ bool A64Emitter::Emit(hir::HIRBuilder* builder, EmitFunctionInfo& func_info) { return true; } +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 +void A64Emitter::EmitTitleStopPollIOS() { + const uintptr_t stop_word = + processor_ ? processor_->title_stop_requested_address_ios() : 0; + if (!stop_word) { + return; + } + + mov(x15, static_cast(stop_word)); + ldr(w15, ptr(x15)); + Label continue_execution; + cbz(w15, continue_execution); + + CallNativeSafe( + reinterpret_cast(&ExitCurrentGuestThreadForTitleStopIOS)); + b(epilog_label()); + + L(continue_execution); +} +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 + void* A64Emitter::Emplace(const EmitFunctionInfo& func_info, GuestFunction* function) { assert_true(func_info.code_size.total == getSize()); @@ -403,6 +451,10 @@ void A64Emitter::UnimplementedInstr(const hir::Instr* i) { void A64Emitter::Call(const hir::Instr* instr, GuestFunction* function) { assert_not_null(function); ForgetFpcrMode(); + if (TryInlinePPCGprLrSaveRestore(instr, function)) { + return; + } + auto fn = static_cast(function); if (fn->machine_code()) { @@ -488,6 +540,128 @@ void A64Emitter::Call(const hir::Instr* instr, GuestFunction* function) { } } +void A64Emitter::TailCallGuestAddressInW16() { + if (code_cache_->has_indirection_table()) { + // Must leave the guest address in w16 for the resolve thunk to read. + if (!code_cache_->encoded_indirection()) { + // Fast path: table mapped at host VA == guest addr; slot holds raw + // 32-bit host target. + ldr(w9, ptr(x16, static_cast(0))); + } else { + // Encoded path: see A64CodeCache for the entry format. + Label external_target; + Label indirection_ready; + + mov(x14, code_cache_->indirection_table_base_bias()); + add(x14, x14, w16, UXTW); + ldr(w9, ptr(x14, static_cast(0))); + tbnz(w9, 31, external_target); + + // Internal: rel32 from code cache base. + mov(x14, code_cache_->execute_base_address()); + add(x9, x14, w9, UXTW); + b(indirection_ready); + + // External: tagged index into the side table. + L(external_target); + and_(w15, w9, A64CodeCache::kIndirectionExternalIndexMask); + mov(x14, code_cache_->external_indirection_table_base_address()); + lsl(x15, x15, 3); + add(x14, x14, x15); + ldr(x9, ptr(x14, static_cast(0))); + + L(indirection_ready); + } + } else { + // No indirection table: resolve at runtime. + mov(x0, x20); // context + mov(x1, x16); // guest address + mov(x9, reinterpret_cast(&ResolveFunction)); + blr(x9); + mov(x9, x0); // resolved address + } + + PopStackpoint(); + ldr(x0, ptr(sp, static_cast(StackLayout::GUEST_RET_ADDR))); + ldr(x30, ptr(sp, static_cast(StackLayout::HOST_RET_ADDR))); + if (stack_size() <= 4095) { + add(sp, sp, static_cast(stack_size())); + } else { + mov(x17, static_cast(stack_size())); + add(sp, sp, x17, UXTX); + } + br(x9); +} + +bool A64Emitter::TryInlinePPCGprLrSaveRestore(const hir::Instr* instr, + const GuestFunction* function) { + if (!function->IsSaverest() || + function->SaverestType() != SaveRestoreType::GPR) { + return false; + } + + const unsigned first_gpr = function->SaverestIndex(); + if (first_gpr < 14 || first_gpr > 31) { + return false; + } + + const bool is_tail_call = (instr->flags & hir::CALL_TAIL) != 0; + if ((function->IsSave() && is_tail_call) || + (function->IsRestore() && !is_tail_call)) { + return false; + } + + // Standard PPC helper layout: + // std/ld rN, -((33 - N) * 8)(r1), N = first_gpr..31 + // stw/lwz r12, -8(r1) + const uint32_t first_slot_offset = (33 - first_gpr) * 8; + const uint32_t lr_slot_offset = (32 - first_gpr) * 8; + + ldr(w14, ptr(x20, static_cast(offsetof(ppc::PPCContext, r[1])))); + if (xe::memory::allocation_granularity() > 0x1000) { + auto& normal_address = NewCachedLabel(); + mov(w15, 0xE0000000u); + cmp(w14, w15); + b(LO, normal_address); + add(w14, w14, 1, 12); // add 0x1000 via LSL #12 + L(normal_address); + } + add(x14, x21, w14, UXTW); + sub(x14, x14, first_slot_offset); + + if (function->IsSave()) { + for (unsigned guest_reg = first_gpr; guest_reg <= 31; ++guest_reg) { + ldr(x15, ptr(x20, static_cast( + offsetof(ppc::PPCContext, r[guest_reg])))); + rev(x15, x15); + str(x15, ptr(x14, static_cast((guest_reg - first_gpr) * 8))); + } + + ldr(w15, ptr(x20, static_cast(offsetof(ppc::PPCContext, r[12])))); + rev(w15, w15); + str(w15, ptr(x14, lr_slot_offset)); + return true; + } + + for (unsigned guest_reg = first_gpr; guest_reg <= 31; ++guest_reg) { + ldr(x15, ptr(x14, static_cast((guest_reg - first_gpr) * 8))); + rev(x15, x15); + str(x15, ptr(x20, static_cast( + offsetof(ppc::PPCContext, r[guest_reg])))); + } + + ldr(w16, ptr(x14, lr_slot_offset)); + rev(w16, w16); + str(x16, ptr(x20, static_cast(offsetof(ppc::PPCContext, r[12])))); + str(x16, ptr(x20, static_cast(offsetof(ppc::PPCContext, lr)))); + + ldr(w15, ptr(sp, static_cast(StackLayout::GUEST_RET_ADDR))); + cmp(w16, w15); + b(EQ, epilog_label()); + TailCallGuestAddressInW16(); + return true; +} + void A64Emitter::CallIndirect(const hir::Instr* instr, int reg_index) { ForgetFpcrMode(); auto target_w = WReg(reg_index); @@ -738,9 +912,8 @@ void A64Emitter::EnsureSynchronizedGuestAndHostStack() { // still point at a skipped frame here. auto& return_from_sync = NewCachedLabel(); - ldr(w16, ptr(x19, static_cast( - offsetof(A64BackendContext, - pending_stackpoint_sync_depth)))); + ldr(w16, ptr(x19, static_cast(offsetof( + A64BackendContext, pending_stackpoint_sync_depth)))); cbz(w16, return_from_sync); auto& sync_label = AddToTail([](A64Emitter& e, Label& lbl) { diff --git a/src/xenia/cpu/backend/a64/a64_emitter.h b/src/xenia/cpu/backend/a64/a64_emitter.h index 094a9f92f..a5a03b44d 100644 --- a/src/xenia/cpu/backend/a64/a64_emitter.h +++ b/src/xenia/cpu/backend/a64/a64_emitter.h @@ -118,6 +118,9 @@ class A64Emitter : public Xbyak_aarch64::CodeGenerator { void Call(const hir::Instr* instr, GuestFunction* function); void CallIndirect(const hir::Instr* instr, int reg_index); void CallExtern(const hir::Instr* instr, const Function* function); + bool TryInlinePPCGprLrSaveRestore(const hir::Instr* instr, + const GuestFunction* function); + void TailCallGuestAddressInW16(); void CallNative(void* fn); void CallNativeSafe(void* fn); void SetReturnAddress(uint64_t value); @@ -188,6 +191,10 @@ class A64Emitter : public Xbyak_aarch64::CodeGenerator { GuestFunction* function = nullptr); bool Emit(hir::HIRBuilder* builder, EmitFunctionInfo& func_info); +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + void EmitTitleStopPollIOS(); +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 + protected: Processor* processor_ = nullptr; A64Backend* backend_ = nullptr; diff --git a/src/xenia/cpu/backend/code_cache_base.h b/src/xenia/cpu/backend/code_cache_base.h index 5cb39395d..bb396c6a2 100644 --- a/src/xenia/cpu/backend/code_cache_base.h +++ b/src/xenia/cpu/backend/code_cache_base.h @@ -11,6 +11,7 @@ #define XENIA_CPU_BACKEND_CODE_CACHE_BASE_H_ #include +#include #include #include #include @@ -33,9 +34,17 @@ #include "xenia/cpu/backend/code_cache.h" #include "xenia/cpu/function.h" -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && XE_ARCH_ARM64 #include #endif +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 +#include +#include +#include +#include +#include +#include +#endif namespace xe { namespace cpu { @@ -103,15 +112,21 @@ class CodeCacheBase : public CodeCache { xe::memory::DeallocationType::kRelease); } if (mapping_ != xe::memory::kFileMappingHandleInvalid) { - if (generated_code_write_base_ && - generated_code_write_base_ != generated_code_execute_base_) { - xe::memory::UnmapFileView(mapping_, generated_code_write_base_, - kGeneratedCodeSize); - } - if (generated_code_execute_base_) { - xe::memory::UnmapFileView(mapping_, generated_code_execute_base_, - kGeneratedCodeSize); +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (!generated_code_uses_ios_persistent_mapping_) { +#endif + if (generated_code_write_base_ && + generated_code_write_base_ != generated_code_execute_base_) { + xe::memory::UnmapFileView(mapping_, generated_code_write_base_, + kGeneratedCodeSize); + } + if (generated_code_execute_base_) { + xe::memory::UnmapFileView(mapping_, generated_code_execute_base_, + kGeneratedCodeSize); + } +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 } +#endif xe::memory::CloseFileMappingHandle(mapping_, file_name_); mapping_ = xe::memory::kFileMappingHandleInvalid; } @@ -155,6 +170,12 @@ class CodeCacheBase : public CodeCache { // Virtual so that platform-specific derived classes (Win32/POSIX) can // override and chain up to add unwind registration. virtual bool Initialize() { + generated_code_uses_mprotect_flip_ = false; + generated_code_uses_vm_remap_fallback_ = false; +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + generated_code_uses_ios_persistent_mapping_ = false; +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 + file_name_ = fmt::format("xenia_code_cache_{}", Clock::QueryHostTickCount()); mapping_ = xe::memory::CreateFileMappingHandle( @@ -174,6 +195,9 @@ class CodeCacheBase : public CodeCache { bool try_fast_indirection = allow_fast_indirection_; #if XE_PLATFORM_MAC && XE_ARCH_AMD64 try_fast_indirection = false; +#endif +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + try_fast_indirection = false; #endif if (try_fast_indirection) { indirection_table_base_ = @@ -240,7 +264,7 @@ class CodeCacheBase : public CodeCache { // Try the preferred fixed address first; fall back to OS-chosen on fail. if (wx_preferred) { -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS // macOS allows RWX only on anonymous MAP_JIT regions; the W^X gate // happens via pthread_jit_write_protect_np in PlaceGuestCode/PlaceData. generated_code_execute_base_ = reinterpret_cast( @@ -263,6 +287,82 @@ class CodeCacheBase : public CodeCache { generated_code_write_base_ = generated_code_execute_base_; #endif } else { +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + const bool use_txm_broker_path = IOSUseTXMBrokerPath(); + if (use_txm_broker_path) { + const int ios_major_version = IOSProductMajorVersion(); + if (ios_major_version > 0) { + XELOGI("iOS JIT: TXM detected on iOS {}; using broker-capable path", + ios_major_version); + } else { + XELOGI("iOS JIT: TXM detected; using broker-capable path"); + } + + if (AcquireIOSPersistentGeneratedCodeMapping( + generated_code_execute_base_, generated_code_write_base_)) { + generated_code_uses_vm_remap_fallback_ = true; + generated_code_uses_ios_persistent_mapping_ = true; + } else { + generated_code_execute_base_ = nullptr; + generated_code_write_base_ = nullptr; + XELOGW("iOS JIT persistent dual-map setup failed"); + } + } else if (IOSHasTXM()) { + const int ios_major_version = IOSProductMajorVersion(); + if (ios_major_version > 0) { + XELOGI( + "iOS JIT: TXM detected on iOS {}; using non-broker W^X defaults", + ios_major_version); + } else { + XELOGI( + "iOS JIT: TXM detected with unknown OS version; using " + "non-broker W^X defaults"); + } + } else { + const int ios_major_version = IOSProductMajorVersion(); + if (ios_major_version > 0) { + XELOGI( + "iOS JIT: non-TXM path on iOS {}; using non-broker W^X " + "defaults", + ios_major_version); + } else { + XELOGI("iOS JIT: non-TXM path; using non-broker W^X defaults"); + } + } + + if (!generated_code_execute_base_ || !generated_code_write_base_) { + if (generated_code_execute_base_) { + munmap(generated_code_execute_base_, kGeneratedCodeSize); + } + generated_code_execute_base_ = nullptr; + generated_code_write_base_ = nullptr; + generated_code_uses_vm_remap_fallback_ = false; + generated_code_uses_ios_persistent_mapping_ = false; + + generated_code_write_base_ = reinterpret_cast( + mmap(nullptr, kGeneratedCodeSize, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)); + if (generated_code_write_base_ == MAP_FAILED) { + generated_code_write_base_ = nullptr; + XELOGE("Unable to allocate iOS JIT code cache (RX mapping)"); + return false; + } + generated_code_execute_base_ = generated_code_write_base_; + generated_code_uses_mprotect_flip_ = true; + if (use_txm_broker_path) { + XELOGI("iOS JIT mprotect-flip fallback active (TXM/broker path)"); + } else if (IOSHasTXM()) { + XELOGI( + "iOS JIT mprotect-flip fallback active (TXM, non-broker path)"); + } else { + XELOGI( + "iOS JIT mprotect-flip fallback active (non-TXM, no external " + "BRK)"); + } + XELOGI("iOS JIT mprotect-flip mapping (RX base): {:p}", + static_cast(generated_code_execute_base_)); + } +#else generated_code_execute_base_ = reinterpret_cast(xe::memory::MapFileView( mapping_, reinterpret_cast(kGeneratedCodeExecuteBase), @@ -282,6 +382,7 @@ class CodeCacheBase : public CodeCache { xe::memory::MapFileView(mapping_, nullptr, kGeneratedCodeSize, xe::memory::PageAccess::kReadWrite, 0)); } +#endif } if (!generated_code_execute_base_ || !generated_code_write_base_) { XELOGE("Unable to allocate code cache generated code storage"); @@ -436,6 +537,13 @@ class CodeCacheBase : public CodeCache { { auto global_lock = global_critical_region_.Acquire(); +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (generated_code_uses_mprotect_flip_) { + generated_code_offset_ = + xe::align(generated_code_offset_, xe::memory::page_size()); + } +#endif + code_execute_address = generated_code_execute_base_ + generated_code_offset_; code_execute_address_out = code_execute_address; @@ -453,6 +561,8 @@ class CodeCacheBase : public CodeCache { auto end_write_address = generated_code_write_base_ + generated_code_offset_; + const size_t write_span_length = + static_cast(end_write_address - code_write_address); size_t high_mark = generated_code_offset_; @@ -465,7 +575,22 @@ class CodeCacheBase : public CodeCache { // Commit memory if needed. EnsureCommitted(high_mark); -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (generated_code_uses_mprotect_flip_ && + !RegionLockRead(code_write_address, write_span_length)) { + XELOGE("iOS JIT mprotect flip: failed to lock code range for writes"); + assert_always(); + } + if (generated_code_uses_mprotect_flip_ && + !RegionUnlockWrite(code_write_address, write_span_length)) { + XELOGE( + "iOS JIT mprotect flip: failed to enable writes before code " + "publish"); + assert_always(); + } +#endif + +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && XE_ARCH_ARM64 // Toggle the per-thread MAP_JIT write gate around writes. Only needed // when execute and write bases alias (single MAP_JIT region); a // separate RW view needs no gating. @@ -489,19 +614,41 @@ class CodeCacheBase : public CodeCache { self().PlaceCode(guest_address, machine_code, func_info, code_execute_address, unwind_reservation); -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && XE_ARCH_ARM64 if (jit_write_toggle) { pthread_jit_write_protect_np(1); } #endif // Flush I-cache for code and fill regions. +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + auto* tail_execute_address = + generated_code_execute_base_ + + (tail_write_address - generated_code_write_base_); + self().FlushCodeRange(code_execute_address, func_info.code_size.total); + if (tail_execute_address < + generated_code_execute_base_ + + (end_write_address - generated_code_write_base_)) { + self().FlushCodeRange( + tail_execute_address, + static_cast(end_write_address - tail_write_address)); + } +#else self().FlushCodeRange(code_write_address, func_info.code_size.total); if (tail_write_address < end_write_address) { self().FlushCodeRange( tail_write_address, static_cast(end_write_address - tail_write_address)); } +#endif + +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (generated_code_uses_mprotect_flip_ && + !RegionSetExec(code_execute_address, write_span_length)) { + XELOGE("iOS JIT mprotect flip: failed to restore RX after code write"); + assert_always(); + } +#endif } // Post-placement hook (e.g. VTune notification). @@ -519,12 +666,33 @@ class CodeCacheBase : public CodeCache { uint8_t* data_address = nullptr; { auto global_lock = global_critical_region_.Acquire(); +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (generated_code_uses_mprotect_flip_) { + generated_code_offset_ = + xe::align(generated_code_offset_, xe::memory::page_size()); + } +#endif data_address = generated_code_write_base_ + generated_code_offset_; generated_code_offset_ += xe::round_up(length, 16); high_mark = generated_code_offset_; } EnsureCommitted(high_mark); -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + const size_t reserved_length = xe::round_up(length, 16); + if (generated_code_uses_mprotect_flip_ && + !RegionLockRead(data_address, reserved_length)) { + XELOGE("iOS JIT mprotect flip: failed to lock data range for writes"); + assert_always(); + } + if (generated_code_uses_mprotect_flip_ && + !RegionUnlockWrite(data_address, reserved_length)) { + XELOGE( + "iOS JIT mprotect flip: failed to enable writes before data " + "publish"); + assert_always(); + } +#endif +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && XE_ARCH_ARM64 const bool jit_write_toggle = generated_code_execute_base_ == generated_code_write_base_; if (jit_write_toggle) { @@ -532,10 +700,22 @@ class CodeCacheBase : public CodeCache { } #endif std::memcpy(data_address, data, length); -#if XE_PLATFORM_MAC && XE_ARCH_ARM64 +#if XE_PLATFORM_APPLE && !XE_PLATFORM_IOS && XE_ARCH_ARM64 if (jit_write_toggle) { pthread_jit_write_protect_np(1); } +#endif +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (generated_code_uses_mprotect_flip_ && + !RegionLockRead(data_address, reserved_length)) { + XELOGE("iOS JIT mprotect flip: failed to lock data range before RX"); + assert_always(); + } + if (generated_code_uses_mprotect_flip_ && + !RegionSetExec(data_address, reserved_length)) { + XELOGE("iOS JIT mprotect flip: failed to restore RX after data write"); + assert_always(); + } #endif return uint32_t(uintptr_t(data_address)); } @@ -604,6 +784,11 @@ class CodeCacheBase : public CodeCache { uint8_t* indirection_table_base_ = nullptr; uint8_t* generated_code_execute_base_ = nullptr; uint8_t* generated_code_write_base_ = nullptr; + bool generated_code_uses_vm_remap_fallback_ = false; + bool generated_code_uses_mprotect_flip_ = false; +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + bool generated_code_uses_ios_persistent_mapping_ = false; +#endif // XE_PLATFORM_IOS && XE_ARCH_ARM64 size_t generated_code_offset_ = 0; std::atomic generated_code_commit_mark_ = {0}; std::vector> generated_code_map_; @@ -619,6 +804,434 @@ class CodeCacheBase : public CodeCache { private: Derived& self() { return static_cast(*this); } +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + static std::atomic& IOSExternalPrepareIssued() { + static std::atomic issued{false}; + return issued; + } + + static std::atomic& IOSExternalDetachIssued() { + static std::atomic issued{false}; + return issued; + } + + struct IOSPersistentGeneratedCodeMapping { + std::mutex mutex; + uint8_t* execute_base = nullptr; + uint8_t* write_base = nullptr; + bool initialized = false; + }; + + static IOSPersistentGeneratedCodeMapping& + IOSPersistentGeneratedCodeMappingState() { + static IOSPersistentGeneratedCodeMapping state; + return state; + } + + static bool AcquireIOSPersistentGeneratedCodeMapping(uint8_t*& execute_base, + uint8_t*& write_base) { + auto& state = IOSPersistentGeneratedCodeMappingState(); + std::lock_guard lock(state.mutex); + if (state.initialized) { + execute_base = state.execute_base; + write_base = state.write_base; + return true; + } + + auto* execute = reinterpret_cast( + mmap(nullptr, kGeneratedCodeSize, PROT_READ | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)); + if (execute == MAP_FAILED) { + XELOGW("iOS JIT persistent dual-map setup (RX base) failed"); + execute_base = nullptr; + write_base = nullptr; + return false; + } + + if (!MaybeRequestExternalJitPrepare(execute, kGeneratedCodeSize)) { + XELOGW("iOS JIT TXM: persistent external prepare request failed"); + } + + vm_address_t remap_addr = 0; + vm_prot_t cur_prot = 0; + vm_prot_t max_prot = 0; + const kern_return_t remap_result = vm_remap( + mach_task_self(), &remap_addr, kGeneratedCodeSize, 0, VM_FLAGS_ANYWHERE, + mach_task_self(), reinterpret_cast(execute), FALSE, + &cur_prot, &max_prot, VM_INHERIT_NONE); + if (remap_result != KERN_SUCCESS) { + XELOGW("iOS JIT persistent dual-mapping: vm_remap failed (kr={})", + remap_result); + munmap(execute, kGeneratedCodeSize); + execute_base = nullptr; + write_base = nullptr; + return false; + } + + auto* write = reinterpret_cast(remap_addr); + if (mprotect(write, kGeneratedCodeSize, PROT_READ | PROT_WRITE) != 0) { + XELOGE( + "iOS JIT persistent dual-mapping: mprotect RW alias failed " + "addr=0x{:X} len=0x{:X} err={} ({})", + reinterpret_cast(write), + static_cast(kGeneratedCodeSize), errno, + std::strerror(errno)); + vm_deallocate(mach_task_self(), remap_addr, kGeneratedCodeSize); + munmap(execute, kGeneratedCodeSize); + execute_base = nullptr; + write_base = nullptr; + return false; + } + + state.execute_base = execute; + state.write_base = write; + state.initialized = true; + execute_base = execute; + write_base = write; + XELOGI("iOS JIT persistent dual-mapping active: execute={:p} write={:p}", + static_cast(execute_base), static_cast(write_base)); + return true; + } + + static std::string FindChildWithNameLength(const std::string& directory, + size_t name_length) { + DIR* dir = opendir(directory.c_str()); + if (!dir) { + return std::string(); + } + + std::string found; + while (dirent* entry = readdir(dir)) { + const char* name = entry->d_name; + if (!name || name[0] == '.') { + continue; + } + if (std::strlen(name) == name_length) { + found = directory + "/" + name; + break; + } + } + closedir(dir); + return found; + } + + static bool IOSHasTXM() { + static const bool has_txm = []() -> bool { + if (const char* env = std::getenv("HAS_TXM")) { + if (env[0] == '1' && env[1] == '\0') { + return true; + } + if (env[0] == '0' && env[1] == '\0') { + return false; + } + } + + const std::string preboot_uuid = + FindChildWithNameLength("/System/Volumes/Preboot", 36); + if (!preboot_uuid.empty()) { + const std::string txm_root = + FindChildWithNameLength(preboot_uuid + "/boot", 96); + if (!txm_root.empty()) { + const std::string txm_path = + txm_root + + "/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4"; + if (access(txm_path.c_str(), F_OK) == 0) { + return true; + } + } + } + + const std::string private_preboot_root = + FindChildWithNameLength("/private/preboot", 96); + if (!private_preboot_root.empty()) { + const std::string txm_path = private_preboot_root + + "/usr/standalone/firmware/FUD/" + "Ap,TrustedExecutionMonitor.img4"; + if (access(txm_path.c_str(), F_OK) == 0) { + return true; + } + } + + return false; + }(); + return has_txm; + } + + static int IOSProductMajorVersion() { + static const int major_version = []() -> int { + size_t version_size = 0; + if (sysctlbyname("kern.osproductversion", nullptr, &version_size, nullptr, + 0) != 0 || + version_size == 0) { + return -1; + } + + std::string version(version_size, '\0'); + if (sysctlbyname("kern.osproductversion", version.data(), &version_size, + nullptr, 0) != 0 || + version_size == 0) { + return -1; + } + if (!version.empty() && version.back() == '\0') { + version.pop_back(); + } + if (version.empty()) { + return -1; + } + + int parsed_major = 0; + size_t index = 0; + while (index < version.size() && version[index] >= '0' && + version[index] <= '9') { + parsed_major = parsed_major * 10 + (version[index] - '0'); + ++index; + } + return parsed_major > 0 ? parsed_major : -1; + }(); + return major_version; + } + + static bool IOSUseTXMBrokerPath() { + if (!IOSHasTXM()) { + return false; + } + const int ios_major_version = IOSProductMajorVersion(); + return ios_major_version >= 26; + } + + static bool GetPageAlignedGeneratedCodeRange(void* address, size_t length, + uintptr_t& aligned_start, + size_t& aligned_length) { + if (!length) { + aligned_start = 0; + aligned_length = 0; + return true; + } + const uintptr_t start = reinterpret_cast(address); + const size_t host_page_size = xe::memory::page_size(); + aligned_start = start & ~(host_page_size - 1); + const uintptr_t aligned_end = xe::align(start + length, host_page_size); + if (aligned_end <= aligned_start) { + aligned_length = 0; + return true; + } + aligned_length = aligned_end - aligned_start; + return true; + } + + static vm_prot_t ToMachProtectFlags(xe::memory::PageAccess access) { + switch (access) { + case xe::memory::PageAccess::kNoAccess: + return VM_PROT_NONE; + case xe::memory::PageAccess::kReadOnly: + return VM_PROT_READ; + case xe::memory::PageAccess::kReadWrite: + return VM_PROT_READ | VM_PROT_WRITE; + case xe::memory::PageAccess::kExecuteReadOnly: + return VM_PROT_READ | VM_PROT_EXECUTE; + case xe::memory::PageAccess::kExecuteReadWrite: + return VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE; + default: + assert_unhandled_case(access); + return VM_PROT_NONE; + } + } + + static bool SetPageAlignedAccess(void* address, size_t length, + xe::memory::PageAccess access) { + uintptr_t aligned_start = 0; + size_t aligned_length = 0; + if (!GetPageAlignedGeneratedCodeRange(address, length, aligned_start, + aligned_length) || + !aligned_length) { + return true; + } + return xe::memory::Protect(reinterpret_cast(aligned_start), + aligned_length, access); + } + + static bool SetPageAlignedAccessWithMaxProtRetry( + void* address, size_t length, xe::memory::PageAccess access) { + if (SetPageAlignedAccess(address, length, access)) { + return true; + } + + uintptr_t aligned_start = 0; + size_t aligned_length = 0; + if (!GetPageAlignedGeneratedCodeRange(address, length, aligned_start, + aligned_length) || + !aligned_length) { + return true; + } + + constexpr vm_prot_t kMaxProtect = + VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE; + const kern_return_t max_result = + vm_protect(mach_task_self(), static_cast(aligned_start), + aligned_length, TRUE, kMaxProtect); + if (max_result != KERN_SUCCESS) { + XELOGE( + "iOS JIT code cache: vm_protect max failed addr=0x{:X} len=0x{:X} " + "kr={}", + aligned_start, static_cast(aligned_length), max_result); + return false; + } + + const vm_prot_t target_protect = ToMachProtectFlags(access); + const kern_return_t protect_result = + vm_protect(mach_task_self(), static_cast(aligned_start), + aligned_length, FALSE, target_protect); + if (protect_result != KERN_SUCCESS) { + XELOGE( + "iOS JIT code cache: vm_protect current failed addr=0x{:X} " + "len=0x{:X} prot=0x{:X} kr={}", + aligned_start, static_cast(aligned_length), + static_cast(target_protect), protect_result); + return false; + } + return true; + } + + static bool AccessSatisfies(xe::memory::PageAccess actual, + xe::memory::PageAccess desired) { + const uint32_t actual_bits = static_cast(actual); + const uint32_t desired_bits = static_cast(desired); + return (actual_bits & desired_bits) == desired_bits; + } + + static void InvokeUniversalPrepareBreakpoint(uintptr_t aligned_start, + size_t aligned_length) { + register uint64_t x0 __asm("x0") = static_cast(aligned_start); + register uint64_t x1 __asm("x1") = static_cast(aligned_length); + register uint64_t x16 __asm("x16") = 1; + asm volatile("brk #0xf00d" : "+r"(x0), "+r"(x1), "+r"(x16) : : "memory"); + } + + static void InvokeUniversalDetachBreakpoint() { + register uint64_t x16 __asm("x16") = 0; + asm volatile("brk #0xf00d" : "+r"(x16) : : "memory"); + } + + static bool MaybeRequestExternalJitDetach() { + bool expected = false; + if (!IOSExternalDetachIssued().compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) { + return true; + } + InvokeUniversalDetachBreakpoint(); + return true; + } + + static bool RequestExternalJitPrepare(void* address, size_t length) { + uintptr_t aligned_start = 0; + size_t aligned_length = 0; + if (!GetPageAlignedGeneratedCodeRange(address, length, aligned_start, + aligned_length) || + !aligned_length) { + return true; + } + InvokeUniversalPrepareBreakpoint(aligned_start, aligned_length); + MaybeRequestExternalJitDetach(); + return true; + } + + static bool MaybeRequestExternalJitPrepare(void* address, size_t length) { + bool expected = false; + if (!IOSExternalPrepareIssued().compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) { + return true; + } + return RequestExternalJitPrepare(address, length); + } + + static bool SetPageAlignedAccessWithExternalPrepareFallback( + void* address, size_t length, xe::memory::PageAccess desired_access, + const char* transition_name) { + const bool use_txm_broker_path = IOSUseTXMBrokerPath(); + if (use_txm_broker_path) { + if (SetPageAlignedAccessWithMaxProtRetry(address, length, + desired_access)) { + return true; + } + } else if (SetPageAlignedAccess(address, length, desired_access)) { + return true; + } + + if (!use_txm_broker_path) { + const int ios_major_version = IOSProductMajorVersion(); + if (ios_major_version > 0) { + XELOGW( + "iOS JIT mprotect flip: {} denied on iOS {} without RWX retry or " + "external brk fallback (non-broker path)", + transition_name, ios_major_version); + } else { + XELOGW( + "iOS JIT mprotect flip: {} denied without RWX retry or external " + "brk fallback (non-broker path)", + transition_name); + } + return false; + } + + XELOGW( + "iOS JIT mprotect flip: {} denied, requesting external prepare via " + "brk #0xf00d (x16=1)", + transition_name); + if (!MaybeRequestExternalJitPrepare(address, length)) { + return false; + } + + if (SetPageAlignedAccessWithMaxProtRetry(address, length, desired_access)) { + return true; + } + + uintptr_t aligned_start = 0; + size_t aligned_length = 0; + if (!GetPageAlignedGeneratedCodeRange(address, length, aligned_start, + aligned_length) || + !aligned_length) { + return true; + } + + size_t query_length = 0; + xe::memory::PageAccess query_access = xe::memory::PageAccess::kNoAccess; + const bool query_ok = xe::memory::QueryProtect( + reinterpret_cast(aligned_start), query_length, query_access); + if (query_ok && AccessSatisfies(query_access, desired_access)) { + return true; + } + + XELOGE( + "iOS JIT mprotect flip: external prepare did not yield {} mapping " + "addr=0x{:X} len=0x{:X} query_ok={} query_access={} query_len=0x{:X}", + transition_name, aligned_start, static_cast(aligned_length), + query_ok, static_cast(query_access), + static_cast(query_length)); + return false; + } + + static bool RegionLockRead(void* address, size_t length) { + if (IOSUseTXMBrokerPath()) { + return SetPageAlignedAccessWithMaxProtRetry( + address, length, xe::memory::PageAccess::kReadOnly); + } + return SetPageAlignedAccess(address, length, + xe::memory::PageAccess::kReadOnly); + } + + static bool RegionUnlockWrite(void* address, size_t length) { + return SetPageAlignedAccessWithExternalPrepareFallback( + address, length, xe::memory::PageAccess::kReadWrite, "RW transition"); + } + + static bool RegionSetExec(void* address, size_t length) { + return SetPageAlignedAccessWithExternalPrepareFallback( + address, length, xe::memory::PageAccess::kExecuteReadOnly, + "RX transition"); + } + +#endif + void EnsureCommitted(size_t high_mark) { using namespace xe::literals; size_t old_commit_mark, new_commit_mark; @@ -628,18 +1241,25 @@ class CodeCacheBase : public CodeCache { break; } new_commit_mark = old_commit_mark + 16_MiB; - if (generated_code_execute_base_ == generated_code_write_base_) { - xe::memory::AllocFixed(generated_code_execute_base_, new_commit_mark, - xe::memory::AllocationType::kCommit, - xe::memory::PageAccess::kExecuteReadWrite); - } else { - xe::memory::AllocFixed(generated_code_execute_base_, new_commit_mark, - xe::memory::AllocationType::kCommit, - xe::memory::PageAccess::kExecuteReadOnly); - xe::memory::AllocFixed(generated_code_write_base_, new_commit_mark, - xe::memory::AllocationType::kCommit, - xe::memory::PageAccess::kReadWrite); +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 + if (!generated_code_uses_vm_remap_fallback_ && + !generated_code_uses_mprotect_flip_) { +#endif + if (generated_code_execute_base_ == generated_code_write_base_) { + xe::memory::AllocFixed(generated_code_execute_base_, new_commit_mark, + xe::memory::AllocationType::kCommit, + xe::memory::PageAccess::kExecuteReadWrite); + } else { + xe::memory::AllocFixed(generated_code_execute_base_, new_commit_mark, + xe::memory::AllocationType::kCommit, + xe::memory::PageAccess::kExecuteReadOnly); + xe::memory::AllocFixed(generated_code_write_base_, new_commit_mark, + xe::memory::AllocationType::kCommit, + xe::memory::PageAccess::kReadWrite); + } +#if XE_PLATFORM_IOS && XE_ARCH_ARM64 } +#endif } while (generated_code_commit_mark_.compare_exchange_weak( old_commit_mark, new_commit_mark)); } diff --git a/src/xenia/cpu/mmio_handler.cc b/src/xenia/cpu/mmio_handler.cc index 3c2134395..29db5599b 100644 --- a/src/xenia/cpu/mmio_handler.cc +++ b/src/xenia/cpu/mmio_handler.cc @@ -457,7 +457,7 @@ bool MMIOHandler::ExceptionCallback(Exception* ex) { // clears the watch we just hit). // Do this under the lock so we don't introduce another race condition. auto lock = global_critical_region_.Acquire(); -#if XE_PLATFORM_LINUX || XE_PLATFORM_MAC +#if XE_PLATFORM_LINUX || XE_PLATFORM_APPLE // POSIX exception handling runs inside a signal handler (SIGSEGV / SIGBUS). // QueryProtect is not async-signal-safe on either platform: Linux reads // /proc/self/maps via std::ifstream, and macOS issues a mach_msg via diff --git a/src/xenia/cpu/ppc/testing/CMakeLists.txt b/src/xenia/cpu/ppc/testing/CMakeLists.txt index 8769bc475..d934ba567 100644 --- a/src/xenia/cpu/ppc/testing/CMakeLists.txt +++ b/src/xenia/cpu/ppc/testing/CMakeLists.txt @@ -4,6 +4,7 @@ endif() add_executable(xenia-cpu-ppc-tests ${CMAKE_CURRENT_SOURCE_DIR}/ppc_testing_main.cc + ${PROJECT_SOURCE_DIR}/src/xenia/cpu/testing/test_backend_cvars.cc ) if(WIN32) diff --git a/src/xenia/cpu/processor.cc b/src/xenia/cpu/processor.cc index 1eb214298..52efe4536 100644 --- a/src/xenia/cpu/processor.cc +++ b/src/xenia/cpu/processor.cc @@ -105,6 +105,10 @@ Processor::~Processor() { } bool Processor::Setup(std::unique_ptr backend) { +#if XE_PLATFORM_IOS + ClearTitleStopRequestIOS(); +#endif // XE_PLATFORM_IOS + // TODO(benvanik): query mode from debugger? debug_info_flags_ = 0; diff --git a/src/xenia/cpu/processor.h b/src/xenia/cpu/processor.h index 55e1890f3..736c73d5f 100644 --- a/src/xenia/cpu/processor.h +++ b/src/xenia/cpu/processor.h @@ -10,6 +10,7 @@ #ifndef XENIA_CPU_PROCESSOR_H_ #define XENIA_CPU_PROCESSOR_H_ +#include #include #include #include @@ -18,6 +19,7 @@ #include "xenia/base/cvar.h" #include "xenia/base/mapped_memory.h" #include "xenia/base/mutex.h" +#include "xenia/base/platform.h" #include "xenia/cpu/backend/backend.h" #include "xenia/cpu/debug_listener.h" #include "xenia/cpu/entry_table.h" @@ -124,6 +126,21 @@ class Processor { uint64_t Execute(ThreadState* thread_state, uint32_t address, uint64_t args[], size_t arg_count); +#if XE_PLATFORM_IOS + void RequestTitleStopIOS() { + ios_title_stop_requested_.store(1, std::memory_order_release); + } + void ClearTitleStopRequestIOS() { + ios_title_stop_requested_.store(0, std::memory_order_release); + } + bool title_stop_requested_ios() const { + return ios_title_stop_requested_.load(std::memory_order_acquire) != 0; + } + uintptr_t title_stop_requested_address_ios() { + return reinterpret_cast(&ios_title_stop_requested_); + } +#endif // XE_PLATFORM_IOS + bool Save(ByteStream* stream); bool Restore(ByteStream* stream); @@ -279,6 +296,10 @@ class Processor { // TODO(benvanik): cleanup/change structures. std::vector breakpoints_; +#if XE_PLATFORM_IOS + std::atomic ios_title_stop_requested_{0}; +#endif // XE_PLATFORM_IOS + Irql irql_; }; diff --git a/src/xenia/cpu/testing/CMakeLists.txt b/src/xenia/cpu/testing/CMakeLists.txt index 58af0e249..b574b6261 100644 --- a/src/xenia/cpu/testing/CMakeLists.txt +++ b/src/xenia/cpu/testing/CMakeLists.txt @@ -4,6 +4,9 @@ xe_test_suite(xenia-cpu-tests ${CMAKE_CURRENT_SOURCE_DIR} xenia-apu xenia-base xenia-core xenia-cpu xenia-gpu xenia-hid-portal xenia-kernel xenia-ui xenia-patcher ) +target_sources(xenia-cpu-tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/test_backend_cvars.cc +) if(XE_TARGET_X86_64 AND TARGET xenia-cpu-tests) target_link_libraries(xenia-cpu-tests PRIVATE xenia-cpu-backend-x64) elseif(XE_TARGET_AARCH64 AND TARGET xenia-cpu-tests) diff --git a/src/xenia/cpu/testing/test_backend_cvars.cc b/src/xenia/cpu/testing/test_backend_cvars.cc new file mode 100644 index 000000000..a07aeae3e --- /dev/null +++ b/src/xenia/cpu/testing/test_backend_cvars.cc @@ -0,0 +1,15 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/base/cvar.h" + +// CPU tests link emulator setup code, but not the platform app main that owns +// these backend selection cvars. Keep the test defaults headless. +DEFINE_string(apu, "nop", "Audio system used by CPU tests.", "APU"); +DEFINE_string(gpu, "null", "Graphics system used by CPU tests.", "GPU"); diff --git a/src/xenia/cpu/thread_state.cc b/src/xenia/cpu/thread_state.cc index 0e0370526..546896bd1 100644 --- a/src/xenia/cpu/thread_state.cc +++ b/src/xenia/cpu/thread_state.cc @@ -9,6 +9,7 @@ #include "xenia/cpu/thread_state.h" +#include #include #include @@ -22,9 +23,11 @@ namespace xe { namespace cpu { thread_local ThreadState* thread_state_ = nullptr; +std::atomic g_logged_context_fallback{false}; static void* AllocateContext() { size_t granularity = xe::memory::allocation_granularity(); + size_t allocation_size = granularity + sizeof(ppc::PPCContext); for (unsigned pos32 = 0x40; pos32 < 8192; ++pos32) { /* we want our register which points to the context to have 0xE0000000 in @@ -41,9 +44,9 @@ static void* AllocateContext() { uintptr_t context_pre = ((static_cast(pos32) << 32) | 0xE0000000) - granularity; - void* p = memory::AllocFixed( - (void*)context_pre, granularity + sizeof(ppc::PPCContext), - memory::AllocationType::kReserveCommit, memory::PageAccess::kReadWrite); + void* p = memory::AllocFixed((void*)context_pre, allocation_size, + memory::AllocationType::kReserveCommit, + memory::PageAccess::kReadWrite); if (p) { return reinterpret_cast(p) + granularity; // now we have a ctx ptr with the e0 constant in low, @@ -51,6 +54,23 @@ static void* AllocateContext() { } } + void* p = memory::AllocFixed(nullptr, allocation_size, + memory::AllocationType::kReserveCommit, + memory::PageAccess::kReadWrite); + if (p) { + auto* fallback_ctx = reinterpret_cast(p) + granularity; + bool expected = false; + if (g_logged_context_fallback.compare_exchange_strong( + expected, true, std::memory_order_relaxed)) { + XELOGW( + "AllocateContext: using fallback PPC context mapping at {:p} " + "(low32=0x{:08X})", + fallback_ctx, + static_cast(reinterpret_cast(fallback_ctx))); + } + return fallback_ctx; + } + assert_always("giving up on allocating context, likely leaking contexts"); return nullptr; } diff --git a/src/xenia/emulator.cc b/src/xenia/emulator.cc index bc0a2bb57..90345dff6 100644 --- a/src/xenia/emulator.cc +++ b/src/xenia/emulator.cc @@ -233,6 +233,15 @@ void Emulator::Shutdown() { XELOGI("Emulator::Shutdown: teardown complete"); } +#if XE_PLATFORM_IOS +void Emulator::ShutdownForTitleExitIOS() { + const bool was_relaunching = relaunching_; + relaunching_ = true; + Shutdown(); + relaunching_ = was_relaunching; +} +#endif // XE_PLATFORM_IOS + uint32_t Emulator::main_thread_id() { return main_thread_ ? main_thread_->thread_id() : 0; } @@ -317,22 +326,29 @@ X_STATUS Emulator::Setup( export_resolver_ = std::make_unique(); std::unique_ptr backend; + const bool profile_only_mode = + !require_cpu_backend_ && !audio_system_factory_ && + !graphics_system_factory_ && !input_driver_factory_; + if (profile_only_mode) { + backend = std::make_unique(); + } else { #if XE_ARCH_AMD64 - if (cvars::cpu == "x64") { - backend.reset(new xe::cpu::backend::x64::X64Backend()); - } -#elif XE_ARCH_ARM64 - if (cvars::cpu == "a64") { - backend.reset(new xe::cpu::backend::a64::A64Backend()); - } -#endif // XE_ARCH - if (cvars::cpu == "any") { - if (!backend) { -#if XE_ARCH_AMD64 + if (cvars::cpu == "x64") { backend.reset(new xe::cpu::backend::x64::X64Backend()); + } #elif XE_ARCH_ARM64 + if (cvars::cpu == "a64") { backend.reset(new xe::cpu::backend::a64::A64Backend()); + } #endif // XE_ARCH + if (cvars::cpu == "any") { + if (!backend) { +#if XE_ARCH_AMD64 + backend.reset(new xe::cpu::backend::x64::X64Backend()); +#elif XE_ARCH_ARM64 + backend.reset(new xe::cpu::backend::a64::A64Backend()); +#endif // XE_ARCH + } } } if (!backend && !require_cpu_backend_) { @@ -349,8 +365,15 @@ X_STATUS Emulator::Setup( } // Input system persists across relaunch — SDL requires init/quit on the - // same thread, so drivers are attached here on the emulator thread. - if (!input_system_) { + // same thread, so drivers are attached here on the emulator thread. Profile + // services may call Setup before a real input factory exists; don't pin an + // empty input system across the later game-mode setup. +#if XE_PLATFORM_IOS + const bool should_create_input_system = input_driver_factory_ != nullptr; +#else + const bool should_create_input_system = true; +#endif // XE_PLATFORM_IOS + if (!input_system_ && should_create_input_system) { XELOGI("{}: Initializing HID...", __func__); input_system_ = std::make_unique(display_window_); if (!input_system_) { @@ -367,6 +390,19 @@ X_STATUS Emulator::Setup( if (result) { return result; } + } else if (input_system_ && input_system_->driver_count() == 0 && + input_driver_factory_) { + XELOGI("{}: Attaching HID drivers...", __func__); + auto input_drivers = input_driver_factory_(display_window_); + for (size_t i = 0; i < input_drivers.size(); ++i) { + input_system_->AddDriver(std::move(input_drivers[i])); + } + result = input_system_->Setup(); + if (result) { + return result; + } + } else { + result = X_STATUS_SUCCESS; } // Add inputSystem to UI (if imgui is enabled) @@ -956,38 +992,37 @@ X_STATUS Emulator::ProcessContentPackageHeader( kernel_state_->xam_state()->profile_manager()->GetProfile( static_cast(0)); - uint64_t xuid = header->content_metadata.profile_id; - if (header->content_metadata.content_type == XContentType::kSavedGame && - profile) { + const XContentType content_type = + static_cast(header->content_metadata.content_type_value()); + uint64_t xuid = header->content_metadata.profile_id_value(); + if (content_type == XContentType::kSavedGame && profile) { xuid = profile->xuid(); } - installation_info.data_installation_path_ = fmt::format( - "{:016X}/{:08X}/{:08X}/{}", xuid, - header->content_metadata.execution_info.title_id.get(), - static_cast(header->content_metadata.content_type.get()), - path.filename()); + installation_info.data_installation_path_ = + fmt::format("{:016X}/{:08X}/{:08X}/{}", xuid, + header->content_metadata.execution_info.title_id.get(), + static_cast(content_type), path.filename()); - installation_info.header_installation_path_ = fmt::format( - "{:016X}/{:08X}/Headers/{:08X}/{}", xuid, - header->content_metadata.execution_info.title_id.get(), - static_cast(header->content_metadata.content_type.get()), - path.filename()); + installation_info.header_installation_path_ = + fmt::format("{:016X}/{:08X}/Headers/{:08X}/{}", xuid, + header->content_metadata.execution_info.title_id.get(), + static_cast(content_type), path.filename()); installation_info.name_ = xe::to_utf8(header->content_metadata.display_name(XLanguage::kEnglish)); - installation_info.content_type_ = - static_cast(header->content_metadata.content_type); - installation_info.content_size_ = header->content_metadata.content_size; + installation_info.content_type_ = content_type; + installation_info.content_size_ = + header->content_metadata.content_size_value(); installation_info.installation_state_ = InstallState::pending; - if (header->content_metadata.title_thumbnail_size > 0 && - header->content_metadata.title_thumbnail_size <= - vfs::XContentMetadata::kThumbLengthV1) { + const uint32_t title_thumbnail_size = + header->content_metadata.title_thumbnail_size_value(); + if (title_thumbnail_size > 0 && + title_thumbnail_size <= vfs::XContentMetadata::kThumbLengthV1) { installation_info.icon_data_.assign( header->content_metadata.title_thumbnail, - header->content_metadata.title_thumbnail + - header->content_metadata.title_thumbnail_size); + header->content_metadata.title_thumbnail + title_thumbnail_size); } return X_STATUS_SUCCESS; @@ -1865,6 +1900,15 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, return result; } +#if XE_PLATFORM_IOS + auto ios_title_stop_requested = [this]() { + return kernel_state_ && kernel_state_->IsTitleStopRequestedIOS(); + }; + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + // Setup NullDevices for raw HDD partition accesses // Cache/STFC code baked into games tries reading/writing to these // By using a NullDevice that just returns success to all IO requests it @@ -1896,6 +1940,11 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, XELOGI("Loading module {}", module_path); auto module = kernel_state_->LoadUserModule(module_path); if (!module) { +#if XE_PLATFORM_IOS + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS XELOGE("Failed to load user module {}", path); return X_STATUS_NOT_FOUND; } @@ -1912,11 +1961,22 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, return result; } +#if XE_PLATFORM_IOS + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + result = kernel_state_->FinishLoadingUserModule(module); if (XFAILED(result)) { XELOGE("Failed to initialize user module {}", path); return result; } +#if XE_PLATFORM_IOS + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS // Grab the current title ID. xex2_opt_execution_info* info = nullptr; uint32_t workspace_address = 0; @@ -2069,12 +2129,23 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, // skipped until pipelines are ready, so this is safe. By the time actual // gameplay starts, most cached pipelines should be compiled. if (graphics_system_) { +#if XE_PLATFORM_IOS + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS on_shader_storage_initialization(true); graphics_system_->InitializeShaderStorage( cache_root_, title_id_.value(), false, [this]() { on_shader_storage_initialization(false); }); } +#if XE_PLATFORM_IOS + if (ios_title_stop_requested()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + auto main_thread = kernel_state_->LaunchModule(module); if (!main_thread) { return X_STATUS_UNSUCCESSFUL; @@ -2086,11 +2157,17 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, // FinishLoadingUserModule() which will apply TUs and patching to the main // xex. if (cvars::allow_plugins) { - if (plugin_loader_->IsAnyPluginForTitleAvailable(title_id_.value(), - module->hash().value())) { - plugin_loader_->LoadTitlePlugins(title_id_.value(), - module->hash().value()); +#if XE_PLATFORM_IOS + if (!ios_title_stop_requested()) { +#endif // XE_PLATFORM_IOS + if (plugin_loader_->IsAnyPluginForTitleAvailable( + title_id_.value(), module->hash().value())) { + plugin_loader_->LoadTitlePlugins(title_id_.value(), + module->hash().value()); + } +#if XE_PLATFORM_IOS } +#endif // XE_PLATFORM_IOS } // Resume the main thread now. diff --git a/src/xenia/emulator.h b/src/xenia/emulator.h index 1eb0541b3..918c39094 100644 --- a/src/xenia/emulator.h +++ b/src/xenia/emulator.h @@ -23,6 +23,7 @@ #include "xenia/apu/audio_media_player.h" #include "xenia/base/delegate.h" #include "xenia/base/exception_handler.h" +#include "xenia/base/platform.h" #include "xenia/kernel/kernel_state.h" #include "xenia/kernel/util/game_info_database.h" #include "xenia/kernel/util/xlast.h" @@ -195,6 +196,12 @@ class Emulator { // Tears down all subsystems. Called by the destructor and by RelaunchTitle. void Shutdown(); +#if XE_PLATFORM_IOS + // Title-exit teardown for the persistent iOS app shell. Keeps input_system_ + // alive like relaunch paths do because SDL is tied to the app/window + // lifetime. + void ShutdownForTitleExitIOS(); +#endif // XE_PLATFORM_IOS // Mounts scratch, cache, and devkit drives based on cvars. void MountStandardDrives(); diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc index c9508974c..29a23aafe 100644 --- a/src/xenia/gpu/command_processor.cc +++ b/src/xenia/gpu/command_processor.cc @@ -14,6 +14,7 @@ #include "xenia/base/clock.h" #include "xenia/base/cvar.h" #include "xenia/base/logging.h" +#include "xenia/base/platform.h" #include "xenia/base/profiling.h" #include "xenia/base/threading.h" #include "xenia/config.h" @@ -187,6 +188,12 @@ CommandProcessor::CommandProcessor(GraphicsSystem* graphics_system, CommandProcessor::~CommandProcessor() = default; +#if XE_PLATFORM_IOS +bool CommandProcessor::IsTitleStopRequestedIOS() const { + return kernel_state_ && kernel_state_->IsTitleStopRequestedIOS(); +} +#endif // XE_PLATFORM_IOS + bool CommandProcessor::Initialize() { // Initialize the gamma ramps to their default (linear) values - taken from // what games set when starting with the sRGB (return value 1) @@ -508,9 +515,10 @@ void CommandProcessor::WorkerThreadMain() { // event is too high. PrepareForWait(); uint32_t loop_count = 0; + constexpr uint32_t idle_spin_yields = 500; do { // If we spin around too much, revert to a "low-power" state. - if (loop_count > 500) { + if (loop_count >= idle_spin_yields) { constexpr int wait_time_ms = 2; xe::threading::Wait(write_ptr_index_event_.get(), true, std::chrono::milliseconds(wait_time_ms)); diff --git a/src/xenia/gpu/command_processor.h b/src/xenia/gpu/command_processor.h index e7fd7f456..d0e828433 100644 --- a/src/xenia/gpu/command_processor.h +++ b/src/xenia/gpu/command_processor.h @@ -22,6 +22,7 @@ #include #include "xenia/base/math.h" +#include "xenia/base/platform.h" #include "xenia/base/ring_buffer.h" #include "xenia/gpu/register_file.h" #include "xenia/gpu/trace_writer.h" @@ -473,6 +474,10 @@ class CommandProcessor { zpd_force_fake_fallback_ = false; } +#if XE_PLATFORM_IOS + bool IsTitleStopRequestedIOS() const; +#endif // XE_PLATFORM_IOS + #include "pm4_command_processor_declare.h" virtual Shader* LoadShader(xenos::ShaderType shader_type, diff --git a/src/xenia/gpu/graphics_system.cc b/src/xenia/gpu/graphics_system.cc index ab526b14c..9706702b0 100644 --- a/src/xenia/gpu/graphics_system.cc +++ b/src/xenia/gpu/graphics_system.cc @@ -174,9 +174,10 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, kernel_state_, 128 * 1024, 0, [this]() { uint64_t last_frame_time = Clock::QueryGuestTickCount(); - // Sleep for 90% of the vblank duration on Windows/macOS, spin for 10% - // Linux uses full sleep duration due to scheduler quantum issues -#if XE_PLATFORM_WIN32 || XE_PLATFORM_MAC + // Sleep for 90% of the vblank duration on Windows/Apple, spin for + // 10%. Linux uses full sleep duration due to scheduler quantum + // issues. +#if XE_PLATFORM_WIN32 || XE_PLATFORM_APPLE constexpr double duration_scalar = 0.90; #elif XE_PLATFORM_LINUX constexpr double duration_scalar = 1.0; @@ -197,8 +198,8 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, (1000000000.0 / static_cast(vblank_hz)) * duration_scalar); -#if XE_PLATFORM_WIN32 || XE_PLATFORM_MAC - // Windows/macOS: time-gating + 90% sleep + 10% spin +#if XE_PLATFORM_WIN32 || XE_PLATFORM_APPLE + // Windows/Apple: time-gating + 90% sleep + 10% spin. const uint64_t tick_freq = Clock::guest_tick_frequency(); const uint64_t target_duration_ticks = tick_freq / vblank_hz; const uint64_t current_time = Clock::QueryGuestTickCount(); @@ -212,7 +213,7 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, last_frame_time += target_duration_ticks; } MarkVblank(); -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE threading::NanoSleepPrecise(sleep_ns); #else threading::NanoSleep(sleep_ns); @@ -248,18 +249,20 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, } void GraphicsSystem::Shutdown() { - if (command_processor_) { - EndTracing(); - command_processor_->Shutdown(); - command_processor_.reset(); - } - + // The frame limiter thread calls MarkVblank, which touches the command + // processor. Stop it before tearing the command processor down. if (frame_limiter_worker_thread_) { frame_limiter_worker_running_ = false; frame_limiter_worker_thread_->Wait(0, 0, 0, nullptr); frame_limiter_worker_thread_.reset(); } + if (command_processor_) { + EndTracing(); + command_processor_->Shutdown(); + command_processor_.reset(); + } + if (presenter_) { if (app_context_) { app_context_->CallInUIThreadSynchronous([this]() { presenter_.reset(); }); diff --git a/src/xenia/gpu/graphics_system.h b/src/xenia/gpu/graphics_system.h index 66a944d7a..977c65590 100644 --- a/src/xenia/gpu/graphics_system.h +++ b/src/xenia/gpu/graphics_system.h @@ -17,6 +17,7 @@ #include #include #include +#include #include "xenia/cpu/processor.h" #include "xenia/gpu/register_file.h" @@ -114,12 +115,14 @@ class GraphicsSystem { static std::pair GetInternalDisplayResolution(); std::pair GetScaledAspectRatio() const { - return {scaled_aspect_x_, scaled_aspect_y_}; - }; - void SetScaledAspectRatio(uint32_t x, uint32_t y) { - scaled_aspect_x_ = x; - scaled_aspect_y_ = y; - }; + return {scaled_aspect_x_.load(std::memory_order_relaxed), + scaled_aspect_y_.load(std::memory_order_relaxed)}; + } + void SetScaledAspectRatio(uint32_t x, uint32_t y); + using ScaledAspectRatioChangedCallback = + std::function; + void SetScaledAspectRatioChangedCallback( + ScaledAspectRatioChangedCallback callback); protected: GraphicsSystem(); @@ -159,8 +162,10 @@ class GraphicsSystem { bool paused_ = false; - uint32_t scaled_aspect_x_ = 0; - uint32_t scaled_aspect_y_ = 0; + std::atomic scaled_aspect_x_{0}; + std::atomic scaled_aspect_y_{0}; + std::mutex scaled_aspect_ratio_changed_callback_mutex_; + ScaledAspectRatioChangedCallback scaled_aspect_ratio_changed_callback_; private: std::unique_ptr presenter_; diff --git a/src/xenia/gpu/null/CMakeLists.txt b/src/xenia/gpu/null/CMakeLists.txt index 0cd9672e2..5fb7a2287 100644 --- a/src/xenia/gpu/null/CMakeLists.txt +++ b/src/xenia/gpu/null/CMakeLists.txt @@ -1,6 +1,6 @@ add_library(xenia-gpu-null STATIC) xe_platform_sources(xenia-gpu-null ${CMAKE_CURRENT_SOURCE_DIR}) -if(APPLE) +if(APPLE AND NOT XE_PLATFORM_IOS) target_link_libraries(xenia-gpu-null PUBLIC xenia-base xenia-gpu xenia-ui xxhash) else() target_include_directories(xenia-gpu-null PRIVATE diff --git a/src/xenia/gpu/pm4_command_processor_implement.h b/src/xenia/gpu/pm4_command_processor_implement.h index f910b83d2..65eb23268 100644 --- a/src/xenia/gpu/pm4_command_processor_implement.h +++ b/src/xenia/gpu/pm4_command_processor_implement.h @@ -97,10 +97,26 @@ void COMMAND_PROCESSOR::ExecuteIndirectBuffer(uint32_t ptr, reader_.BeginPrefetchedRead( COMMAND_PROCESSOR::GetCurrentRingReadCount()); do { +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: abandoning indirect ringbuffer decode during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS if (COMMAND_PROCESSOR::ExecutePacket()) { continue; } else { // Return up a level if we encounter a bad packet. +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: ignoring indirect ringbuffer decode failure during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS XELOGE("**** INDIRECT RINGBUFFER: Failed to execute packet."); assert_always(); break; @@ -1734,8 +1750,24 @@ uint32_t COMMAND_PROCESSOR::ExecutePrimaryBuffer(uint32_t read_index, reader_.BeginPrefetchedRead( GetCurrentRingReadCount()); do { +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: abandoning primary ringbuffer decode during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS if (!COMMAND_PROCESSOR::ExecutePacket()) { // This probably should be fatal - but we're going to continue anyways. +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: ignoring primary ringbuffer decode failure during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS XELOGE("**** PRIMARY RINGBUFFER: Failed to execute packet."); assert_always(); break; diff --git a/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc b/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc index a0267e406..fce287a1d 100644 --- a/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc +++ b/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc @@ -67,6 +67,27 @@ namespace xe { namespace gpu { namespace vulkan { +namespace { + +bool IsPrimitiveRestartAlwaysEnabledOnMoltenVK( + const ui::vulkan::VulkanDevice::Properties& device_properties, + VkPrimitiveTopology topology) { + if (device_properties.driverID != VK_DRIVER_ID_MOLTENVK) { + return false; + } + + switch (topology) { + case VK_PRIMITIVE_TOPOLOGY_LINE_STRIP: + case VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP: + case VK_PRIMITIVE_TOPOLOGY_TRIANGLE_FAN: + return true; + default: + return false; + } +} + +} // namespace + VulkanPipelineCache::VulkanPipelineCache( VulkanCommandProcessor& command_processor, const RegisterFile& register_file, @@ -2773,8 +2794,15 @@ bool VulkanPipelineCache::EnsurePipelineCreated( assert_unhandled_case(description.primitive_topology); return false; } + // MoltenVK lowers strip/fan primitive restart to Metal, where it can't be + // disabled for these topologies. Request the behavior Metal will use anyway + // so MoltenVK doesn't warn on every affected pipeline. input_assembly_state.primitiveRestartEnable = - description.primitive_restart ? VK_TRUE : VK_FALSE; + description.primitive_restart || + IsPrimitiveRestartAlwaysEnabledOnMoltenVK( + vulkan_device->properties(), input_assembly_state.topology) + ? VK_TRUE + : VK_FALSE; VkPipelineViewportStateCreateInfo viewport_state; viewport_state.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; diff --git a/src/xenia/helper/sdl/sdl_helper.cc b/src/xenia/helper/sdl/sdl_helper.cc index b31f4725d..ddd05c682 100644 --- a/src/xenia/helper/sdl/sdl_helper.cc +++ b/src/xenia/helper/sdl/sdl_helper.cc @@ -12,6 +12,13 @@ #include "xenia/base/assert.h" #include "xenia/base/logging.h" +#if XE_PLATFORM_IOS +#ifndef SDL_MAIN_HANDLED +#define SDL_MAIN_HANDLED +#endif +#include +#endif + namespace xe { namespace helper { namespace sdl { @@ -23,6 +30,12 @@ bool SDLHelper::Prepare() { } is_prepared_ = true; +#if XE_PLATFORM_IOS + // iOS uses UIApplicationMain directly, not SDL_main. + // Tell SDL that app bootstrap is complete before any subsystem init. + SDL_SetMainReady(); +#endif + is_prepared_ &= SetHints(); is_prepared_ &= RedirectLog(); diff --git a/src/xenia/hid/touch/CMakeLists.txt b/src/xenia/hid/touch/CMakeLists.txt new file mode 100644 index 000000000..c922d6627 --- /dev/null +++ b/src/xenia/hid/touch/CMakeLists.txt @@ -0,0 +1,45 @@ +add_library(xenia-hid-touch-model STATIC) + +target_sources(xenia-hid-touch-model PRIVATE + touch_layout_editor.cc + touch_layout_editor.h + touch_input_resolver.cc + touch_input_resolver.h + touch_layout_ios.cc + touch_layout_ios_codec.cc + touch_layout_ios_defaults.cc + touch_layout_ios_geometry.cc + touch_layout_ios.h + touch_layout_ios_internal.h + touch_layout_ios_labels.cc + touch_layout_ios_runtime.cc +) + +target_link_libraries(xenia-hid-touch-model PUBLIC + xenia-base + xenia-hid +) + +xe_target_defaults(xenia-hid-touch-model) + +if(XE_PLATFORM_IOS) + add_library(xenia-hid-touch STATIC) + + target_sources(xenia-hid-touch PRIVATE + touch_hid_ios.cc + touch_hid_ios.h + touch_input_driver_ios.cc + touch_input_driver_ios.h + ) + + target_link_libraries(xenia-hid-touch PUBLIC + xenia-hid + xenia-hid-touch-model + ) + + xe_target_defaults(xenia-hid-touch) +endif() + +if(XENIA_BUILD_TESTS) + add_subdirectory(testing) +endif() diff --git a/src/xenia/hid/touch/testing/CMakeLists.txt b/src/xenia/hid/touch/testing/CMakeLists.txt new file mode 100644 index 000000000..bff193f1a --- /dev/null +++ b/src/xenia/hid/touch/testing/CMakeLists.txt @@ -0,0 +1,3 @@ +xe_test_suite(xenia-hid-touch-tests ${CMAKE_CURRENT_SOURCE_DIR} + LINKS xenia-hid-touch-model xenia-hid xenia-base fmt +) diff --git a/src/xenia/hid/touch/testing/touch_layout_ios_test.cc b/src/xenia/hid/touch/testing/touch_layout_ios_test.cc new file mode 100644 index 000000000..3e4a660b9 --- /dev/null +++ b/src/xenia/hid/touch/testing/touch_layout_ios_test.cc @@ -0,0 +1,336 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios.h" + +#include + +#include "third_party/catch/include/catch.hpp" +#include "xenia/hid/input.h" +#include "xenia/hid/touch/touch_input_resolver.h" +#include "xenia/hid/touch/touch_layout_editor.h" + +namespace xe::hid::touch::test { +namespace { + +const IOSTouchControlDefinition* FindControlByAction( + const IOSTouchLayoutModel& layout, IOSTouchAction action) { + auto it = std::find_if(layout.controls.begin(), layout.controls.end(), + [action](const IOSTouchControlDefinition& control) { + return control.action == action; + }); + return it == layout.controls.end() ? nullptr : &*it; +} + +} // namespace + +TEST_CASE("Default iOS FPS touch layout is valid") { + IOSTouchLayoutModel layout = CreateDefaultIOSFPSLayoutModel(); + + REQUIRE(IsValidIOSTouchLayoutModel(layout)); + REQUIRE_FALSE(layout.layout_id.empty()); + REQUIRE(layout.controls.size() >= 6); + REQUIRE(FindControlByAction(layout, IOSTouchAction::kMove) != nullptr); + REQUIRE(FindControlByAction(layout, IOSTouchAction::kLook) != nullptr); + REQUIRE(FindControlByAction(layout, IOSTouchAction::kButtonA) != nullptr); + REQUIRE(FindControlByAction(layout, IOSTouchAction::kRightTrigger) != + nullptr); +} + +TEST_CASE("Touch action mapping updates XInput output fields") { + IOSTouchControlDefinition control; + + REQUIRE(ConfigureIOSTouchControlAction(IOSTouchAction::kButtonA, &control)); + REQUIRE(control.mapped_buttons == X_INPUT_GAMEPAD_A); + REQUIRE(control.mapped_left_trigger == 0); + REQUIRE(control.mapped_right_trigger == 0); + + REQUIRE( + ConfigureIOSTouchControlAction(IOSTouchAction::kLeftTrigger, &control)); + REQUIRE(control.mapped_buttons == 0); + REQUIRE(control.mapped_left_trigger == 0xFF); + REQUIRE(control.mapped_right_trigger == 0); + + REQUIRE( + ConfigureIOSTouchControlAction(IOSTouchAction::kRightTrigger, &control)); + REQUIRE(control.mapped_buttons == 0); + REQUIRE(control.mapped_left_trigger == 0); + REQUIRE(control.mapped_right_trigger == 0xFF); +} + +TEST_CASE("Touch input resolver hit-tests rounded and circular controls") { + IOSTouchControlDefinition control; + control.shape = IOSTouchControlShape::kCircle; + const IOSTouchRect square_frame{0.0f, 0.0f, 100.0f, 100.0f}; + + REQUIRE(TouchControlContainsPoint(control, square_frame, + IOSTouchPoint{50.0f, 50.0f})); + REQUIRE_FALSE(TouchControlContainsPoint(control, square_frame, + IOSTouchPoint{1.0f, 1.0f})); + + const IOSTouchRect capsule_frame{0.0f, 0.0f, 160.0f, 80.0f}; + REQUIRE(TouchControlContainsPoint(control, capsule_frame, + IOSTouchPoint{80.0f, 40.0f})); + REQUIRE_FALSE(TouchControlContainsPoint(control, capsule_frame, + IOSTouchPoint{5.0f, 5.0f})); + + control.shape = IOSTouchControlShape::kRoundedRect; + REQUIRE(TouchControlContainsPoint(control, square_frame, + IOSTouchPoint{1.0f, 1.0f})); + REQUIRE_FALSE(TouchControlContainsPoint(control, square_frame, + IOSTouchPoint{101.0f, 50.0f})); +} + +TEST_CASE("Touch input resolver resolves Move and D-pad combo zones") { + IOSTouchControlDefinition control; + control.type = IOSTouchControlType::kMoveStick; + control.move_with_dpad_ring = true; + const IOSTouchRect frame{0.0f, 0.0f, 100.0f, 100.0f}; + + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{50.0f, 50.0f}) == + IOSTouchComboSubzone::kStick); + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{50.0f, 5.0f}) == + IOSTouchComboSubzone::kDpadUp); + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{50.0f, 95.0f}) == + IOSTouchComboSubzone::kDpadDown); + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{5.0f, 50.0f}) == + IOSTouchComboSubzone::kDpadLeft); + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{95.0f, 50.0f}) == + IOSTouchComboSubzone::kDpadRight); + + control.move_with_dpad_ring = false; + REQUIRE( + ResolveTouchComboSubzone(control, frame, IOSTouchPoint{95.0f, 50.0f}) == + IOSTouchComboSubzone::kStick); +} + +TEST_CASE("Touch input resolver maps actions to XInput state") { + IOSTouchResolvedState state; + + ApplyTouchActionMappingForAction(IOSTouchAction::kButtonA, &state); + REQUIRE(state.buttons == X_INPUT_GAMEPAD_A); + REQUIRE(state.left_trigger == 0); + REQUIRE(state.right_trigger == 0); + + ApplyTouchActionMappingForAction(IOSTouchAction::kLeftTrigger, &state); + REQUIRE(state.buttons == X_INPUT_GAMEPAD_A); + REQUIRE(state.left_trigger == 0xFF); + REQUIRE(state.right_trigger == 0); + + IOSTouchControlDefinition control; + REQUIRE( + ConfigureIOSTouchControlAction(IOSTouchAction::kRightBumper, &control)); + ApplyTouchActionMapping(control, &state); + REQUIRE(state.buttons == + (X_INPUT_GAMEPAD_A | X_INPUT_GAMEPAD_RIGHT_SHOULDER)); +} + +TEST_CASE("Touch input resolver clamps swipe-look vectors") { + IOSTouchPoint clamped = TouchSwipeLookVectorForDelta( + IOSTouchPoint{12.0f, -3.0f}, 2.0f, 4.0f, 1.5f); + REQUIRE(clamped.x == Approx(1.0f)); + REQUIRE(clamped.y == Approx(1.0f)); + + IOSTouchPoint scaled = + TouchSwipeLookVectorForDelta(IOSTouchPoint{2.0f, 1.0f}, 0.5f, 4.0f, 2.0f); + REQUIRE(scaled.x == Approx(0.25f)); + REQUIRE(scaled.y == Approx(-0.25f)); +} + +TEST_CASE("Touch input resolver resolves deferred and hold-drag behavior") { + IOSTouchControlDefinition control; + control.type = IOSTouchControlType::kActionButton; + control.secondary_behavior.trigger = IOSTouchInteractionTrigger::kHold; + control.secondary_behavior.action = IOSTouchAction::kButtonX; + + REQUIRE(TouchControlUsesDeferredPrimaryTap(control)); + control.hold_while_captured = true; + REQUIRE_FALSE(TouchControlUsesDeferredPrimaryTap(control)); + + IOSTouchInteractionBehavior behavior; + behavior.trigger = IOSTouchInteractionTrigger::kHoldDrag; + behavior.action = IOSTouchAction::kButtonY; + behavior.enables_relative_look = true; + behavior.relative_look_scale = 3.0f; + behavior.hold_seconds = 0.10f; + behavior.drag_threshold_points = 10.0f; + + IOSTouchInputCapture capture; + capture.anchor_point = IOSTouchPoint{10.0f, 10.0f}; + capture.current_point = IOSTouchPoint{25.0f, 10.0f}; + capture.began_time = 1.0; + + IOSTouchInteractionBehaviorState state = + ResolveTouchInteractionBehaviorState(behavior, capture, 1.20); + REQUIRE(state.active); + REQUIRE(state.enables_relative_look); + REQUIRE(state.relative_look_scale == Approx(2.0f)); + + capture.current_point = IOSTouchPoint{12.0f, 10.0f}; + state = ResolveTouchInteractionBehaviorState(behavior, capture, 1.20); + REQUIRE_FALSE(state.active); +} + +TEST_CASE("Touch input resolver qualifies Move stick double-tap forward") { + IOSTouchControlDefinition control; + control.type = IOSTouchControlType::kMoveStick; + control.activation_radius = 0.50f; + control.deadzone = 0.0f; + const IOSTouchRect frame{0.0f, 0.0f, 100.0f, 100.0f}; + + IOSTouchInputCapture capture; + capture.anchor_point = IOSTouchPoint{50.0f, 50.0f}; + capture.current_point = IOSTouchPoint{50.0f, 0.0f}; + capture.began_time = 1.0; + + REQUIRE(MoveStickCaptureQualifiesForDoubleTapForward(control, frame, capture, + 1.20)); + REQUIRE_FALSE(MoveStickCaptureQualifiesForDoubleTapForward(control, frame, + capture, 1.50)); + + capture.current_point = IOSTouchPoint{90.0f, 50.0f}; + REQUIRE_FALSE(MoveStickCaptureQualifiesForDoubleTapForward(control, frame, + capture, 1.20)); +} + +TEST_CASE("Touch input resolver compares states without packet churn") { + IOSTouchResolvedState left; + left.packet_number = 1; + left.buttons = X_INPUT_GAMEPAD_A; + left.thumb_lx = 12; + left.gameplay_enabled = true; + + IOSTouchResolvedState right = left; + right.packet_number = 99; + REQUIRE(TouchStatesEqualIgnoringPacket(left, right)); + + right.thumb_lx = 13; + REQUIRE_FALSE(TouchStatesEqualIgnoringPacket(left, right)); +} + +TEST_CASE("Touch layout TOML round trip preserves editable model state") { + IOSTouchLayoutModel layout = CreateDefaultIOSFPSLayoutModel(); + REQUIRE(!layout.controls.empty()); + IOSTouchControlDefinition& control = layout.controls.front(); + SetIOSTouchControlCustomLabel(" Move ", &control); + control.has_portrait_frame = true; + control.portrait_normalized_frame = IOSTouchRect{0.2f, 0.3f, 0.4f, 0.5f}; + control.tint_style = IOSTouchTintStyle::kMint; + + toml::table encoded = EncodeIOSTouchLayoutModel(layout); + IOSTouchLayoutModel decoded; + REQUIRE(ApplyIOSTouchLayoutModel(encoded, &decoded)); + + REQUIRE(decoded.layout_id == layout.layout_id); + REQUIRE(decoded.display_name == layout.display_name); + REQUIRE(decoded.controls.size() == layout.controls.size()); + const IOSTouchControlDefinition& decoded_control = decoded.controls.front(); + REQUIRE(decoded_control.label == "Move"); + REQUIRE_FALSE(decoded_control.label_uses_default); + REQUIRE(decoded_control.has_portrait_frame); + REQUIRE(decoded_control.portrait_normalized_frame.x == Approx(0.2f)); + REQUIRE(decoded_control.portrait_normalized_frame.y == Approx(0.3f)); + REQUIRE(decoded_control.portrait_normalized_frame.width == Approx(0.4f)); + REQUIRE(decoded_control.portrait_normalized_frame.height == Approx(0.5f)); + REQUIRE(decoded_control.tint_style == IOSTouchTintStyle::kMint); +} + +TEST_CASE("Touch runtime model publishes resolved state snapshots") { + IOSTouchRuntimeModel runtime_model; + + IOSTouchResolvedState state; + state.packet_number = 7; + state.buttons = X_INPUT_GAMEPAD_A | X_INPUT_GAMEPAD_B; + state.left_trigger = 31; + state.right_trigger = 255; + state.thumb_lx = -1234; + state.thumb_ly = 5678; + state.gameplay_enabled = true; + runtime_model.StoreResolvedState(state); + + IOSTouchResolvedState loaded = runtime_model.LoadResolvedState(); + REQUIRE(loaded.packet_number == 7); + REQUIRE(loaded.buttons == (X_INPUT_GAMEPAD_A | X_INPUT_GAMEPAD_B)); + REQUIRE(loaded.left_trigger == 31); + REQUIRE(loaded.right_trigger == 255); + REQUIRE(loaded.thumb_lx == -1234); + REQUIRE(loaded.thumb_ly == 5678); + REQUIRE(loaded.gameplay_enabled); + + runtime_model.ResetResolvedState(); + loaded = runtime_model.LoadResolvedState(); + REQUIRE(loaded.packet_number == 0); + REQUIRE(loaded.buttons == 0); + REQUIRE_FALSE(loaded.gameplay_enabled); +} + +TEST_CASE("Touch layout editor adds and duplicates action buttons") { + IOSTouchLayoutModel layout = CreateDefaultIOSFPSLayoutModel(); + const size_t initial_count = layout.controls.size(); + + std::string added_identifier; + REQUIRE(AddSuggestedActionButtonToIOSTouchLayout(&layout, true, + &added_identifier)); + REQUIRE(layout.controls.size() == initial_count + 1); + REQUIRE_FALSE(added_identifier.empty()); + const IOSTouchControlDefinition& added = layout.controls.back(); + REQUIRE(added.identifier == added_identifier); + REQUIRE(added.type == IOSTouchControlType::kActionButton); + REQUIRE(added.action == IOSTouchAction::kButtonB); + REQUIRE(added.has_portrait_frame); + REQUIRE(added.portrait_normalized_frame.x == + Approx(added.normalized_frame.x)); + const IOSTouchAction added_action = added.action; + const float added_portrait_x = added.portrait_normalized_frame.x; + + std::string duplicate_identifier; + REQUIRE(DuplicateIOSTouchLayoutActionButton( + &layout, layout.controls.size() - 1, true, &duplicate_identifier)); + REQUIRE(layout.controls.size() == initial_count + 2); + const IOSTouchControlDefinition& duplicate = layout.controls.back(); + REQUIRE(duplicate.identifier == duplicate_identifier); + REQUIRE(duplicate_identifier != added_identifier); + REQUIRE(duplicate.action == added_action); + REQUIRE(duplicate.portrait_normalized_frame.x == + Approx(std::min(added_portrait_x + 0.03f, + 1.0f - duplicate.portrait_normalized_frame.width))); +} + +TEST_CASE("Touch layout editor mirrors copies and deletes controls") { + IOSTouchLayoutModel layout = CreateDefaultIOSFPSLayoutModel(); + REQUIRE(layout.controls.size() > 1); + IOSTouchControlDefinition& control = layout.controls.front(); + control.normalized_frame = IOSTouchRect{0.10f, 0.20f, 0.30f, 0.40f}; + + REQUIRE(MirrorIOSTouchLayoutControlHorizontally(&layout, 0, false)); + REQUIRE(layout.controls.front().normalized_frame.x == Approx(0.60f)); + + REQUIRE(CopyIOSTouchLayoutFramesAcrossOrientations(&layout, true)); + REQUIRE(layout.controls.front().has_portrait_frame); + REQUIRE(layout.controls.front().portrait_normalized_frame.x == Approx(0.60f)); + + REQUIRE(CopyIOSTouchLayoutFramesAcrossOrientations(&layout, false)); + REQUIRE(layout.controls.front().normalized_frame.x == Approx(0.60f)); + + const size_t before_delete_count = layout.controls.size(); + REQUIRE(DeleteIOSTouchLayoutControl(&layout, 0)); + REQUIRE(layout.controls.size() == before_delete_count - 1); + + IOSTouchLayoutModel single_control_layout; + single_control_layout.controls.push_back(IOSTouchControlDefinition{}); + REQUIRE_FALSE(DeleteIOSTouchLayoutControl(&single_control_layout, 0)); + REQUIRE(single_control_layout.controls.size() == 1); +} + +} // namespace xe::hid::touch::test diff --git a/src/xenia/hid/touch/touch_hid_ios.cc b/src/xenia/hid/touch/touch_hid_ios.cc new file mode 100644 index 000000000..ef68622f9 --- /dev/null +++ b/src/xenia/hid/touch/touch_hid_ios.cc @@ -0,0 +1,25 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_hid_ios.h" + +#include "xenia/hid/touch/touch_input_driver_ios.h" + +namespace xe { +namespace hid { +namespace touch { + +std::unique_ptr Create(xe::ui::Window* window, + size_t window_z_order) { + return std::make_unique(window, window_z_order); +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_hid_ios.h b/src/xenia/hid/touch/touch_hid_ios.h new file mode 100644 index 000000000..8ea010426 --- /dev/null +++ b/src/xenia/hid/touch/touch_hid_ios.h @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_HID_IOS_H_ +#define XENIA_HID_TOUCH_HID_IOS_H_ + +#include + +#include "xenia/hid/input_driver.h" + +namespace xe { +namespace hid { +namespace touch { + +std::unique_ptr Create(xe::ui::Window* window, + size_t window_z_order); + +} // namespace touch +} // namespace hid +} // namespace xe + +#endif // XENIA_HID_TOUCH_HID_IOS_H_ diff --git a/src/xenia/hid/touch/touch_input_driver_ios.cc b/src/xenia/hid/touch/touch_input_driver_ios.cc new file mode 100644 index 000000000..3419cc4a1 --- /dev/null +++ b/src/xenia/hid/touch/touch_input_driver_ios.cc @@ -0,0 +1,346 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_input_driver_ios.h" + +#include +#include +#include + +#include "xenia/base/logging.h" +#include "xenia/hid/touch/touch_layout_ios.h" +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#include "xenia/ui/virtual_key.h" + +namespace xe { +namespace hid { +namespace touch { + +namespace { + +constexpr uint8_t kTouchTriggerCapability = 0xFF; +constexpr int16_t kTouchThumbCapability = static_cast(0xFFFFu); +constexpr uint8_t kTouchTriggerThreshold = 0x1F; +constexpr int16_t kTouchThumbThreshold = 0x4E00; + +constexpr std::array kTouchVirtualKeys = { + ui::VirtualKey::kXInputPadDpadUp, + ui::VirtualKey::kXInputPadDpadDown, + ui::VirtualKey::kXInputPadDpadLeft, + ui::VirtualKey::kXInputPadDpadRight, + ui::VirtualKey::kXInputPadStart, + ui::VirtualKey::kXInputPadBack, + ui::VirtualKey::kXInputPadLThumbPress, + ui::VirtualKey::kXInputPadRThumbPress, + ui::VirtualKey::kXInputPadLShoulder, + ui::VirtualKey::kXInputPadRShoulder, + ui::VirtualKey::kXInputPadGuide, + ui::VirtualKey::kNone, + ui::VirtualKey::kXInputPadA, + ui::VirtualKey::kXInputPadB, + ui::VirtualKey::kXInputPadX, + ui::VirtualKey::kXInputPadY, + ui::VirtualKey::kXInputPadLTrigger, + ui::VirtualKey::kXInputPadRTrigger, + ui::VirtualKey::kXInputPadLThumbUp, + ui::VirtualKey::kXInputPadLThumbDown, + ui::VirtualKey::kXInputPadLThumbRight, + ui::VirtualKey::kXInputPadLThumbLeft, + ui::VirtualKey::kXInputPadLThumbUpLeft, + ui::VirtualKey::kXInputPadLThumbUpRight, + ui::VirtualKey::kXInputPadLThumbDownRight, + ui::VirtualKey::kXInputPadLThumbDownLeft, + ui::VirtualKey::kXInputPadRThumbUp, + ui::VirtualKey::kXInputPadRThumbDown, + ui::VirtualKey::kXInputPadRThumbRight, + ui::VirtualKey::kXInputPadRThumbLeft, + ui::VirtualKey::kXInputPadRThumbUpLeft, + ui::VirtualKey::kXInputPadRThumbUpRight, + ui::VirtualKey::kXInputPadRThumbDownRight, + ui::VirtualKey::kXInputPadRThumbDownLeft, +}; + +uint64_t TouchAnalogToKeyfield(const IOSTouchResolvedState& state) { + uint64_t keyfield = 0; + keyfield |= uint64_t(state.left_trigger > kTouchTriggerThreshold) << 16; + keyfield |= uint64_t(state.right_trigger > kTouchTriggerThreshold) << 17; + + auto append_thumb = [&keyfield](int16_t thumb_x, int16_t thumb_y, + size_t bit_base) { + uint64_t up = thumb_y > kTouchThumbThreshold; + uint64_t down = thumb_y < -kTouchThumbThreshold; + uint64_t right = thumb_x > kTouchThumbThreshold; + uint64_t left = thumb_x < -kTouchThumbThreshold; + if (up && left) { + up = 0; + left = 0; + keyfield |= uint64_t(1) << (bit_base + 4); + } + if (up && right) { + up = 0; + right = 0; + keyfield |= uint64_t(1) << (bit_base + 5); + } + if (down && right) { + down = 0; + right = 0; + keyfield |= uint64_t(1) << (bit_base + 6); + } + if (down && left) { + down = 0; + left = 0; + keyfield |= uint64_t(1) << (bit_base + 7); + } + keyfield |= up << bit_base; + keyfield |= down << (bit_base + 1); + keyfield |= right << (bit_base + 2); + keyfield |= left << (bit_base + 3); + }; + + append_thumb(state.thumb_lx, state.thumb_ly, 18); + append_thumb(state.thumb_rx, state.thumb_ry, 26); + return keyfield; +} + +uint64_t TouchKeystrokeFieldFromState(const IOSTouchResolvedState& state) { + return uint64_t(state.buttons) | TouchAnalogToKeyfield(state); +} + +void ApplyMappedCapabilities(const IOSTouchControlDefinition& control, + X_INPUT_CAPABILITIES* out_caps) { + if (!out_caps) { + return; + } + out_caps->gamepad.buttons = + uint16_t(out_caps->gamepad.buttons) | control.mapped_buttons; + out_caps->gamepad.left_trigger = std::max( + out_caps->gamepad.left_trigger, + control.mapped_left_trigger ? kTouchTriggerCapability : 0); + out_caps->gamepad.right_trigger = std::max( + out_caps->gamepad.right_trigger, + control.mapped_right_trigger ? kTouchTriggerCapability : 0); +} + +void ApplyLookCapabilities(X_INPUT_CAPABILITIES* out_caps) { + if (!out_caps) { + return; + } + out_caps->gamepad.thumb_rx = kTouchThumbCapability; + out_caps->gamepad.thumb_ry = kTouchThumbCapability; +} + +void ApplyMoveCapabilities(X_INPUT_CAPABILITIES* out_caps) { + if (!out_caps) { + return; + } + out_caps->gamepad.thumb_lx = kTouchThumbCapability; + out_caps->gamepad.thumb_ly = kTouchThumbCapability; +} + +void ApplyActionCapabilities(IOSTouchAction action, + X_INPUT_CAPABILITIES* out_caps) { + IOSTouchControlDefinition mapped_control; + if (!ConfigureIOSTouchControlAction(action, &mapped_control)) { + return; + } + ApplyMappedCapabilities(mapped_control, out_caps); +} + +void ApplyControlCapabilities(const IOSTouchControlDefinition& control, + X_INPUT_CAPABILITIES* out_caps) { + switch (control.type) { + case IOSTouchControlType::kMoveStick: + ApplyMoveCapabilities(out_caps); + break; + case IOSTouchControlType::kLookSwipeZone: + ApplyLookCapabilities(out_caps); + break; + case IOSTouchControlType::kPauseButton: + break; + case IOSTouchControlType::kActionButton: + ApplyMappedCapabilities(control, out_caps); + if (control.enables_relative_look) { + ApplyLookCapabilities(out_caps); + } + break; + } + + if (control.secondary_behavior.trigger != IOSTouchInteractionTrigger::kNone) { + ApplyActionCapabilities(control.secondary_behavior.action, out_caps); + if (control.secondary_behavior.enables_relative_look) { + ApplyLookCapabilities(out_caps); + } + } +} + +} // namespace + +TouchInputDriver::TouchInputDriver(xe::ui::Window* window, + size_t window_z_order) + : InputDriver(window, window_z_order) {} + +TouchInputDriver::~TouchInputDriver() = default; + +X_STATUS TouchInputDriver::Setup() { + RefreshRuntimeModel(); + if (!runtime_model_) { + XELOGW( + "iOS touch input driver: runtime model is unavailable during setup;" + " deferring until first use"); + } + return X_STATUS_SUCCESS; +} + +bool TouchInputDriver::RefreshRuntimeModel() { + if (runtime_model_) { + return true; + } + + auto& app_context = + static_cast(window()->app_context()); + runtime_model_ = app_context.touch_runtime_model(); + return runtime_model_ != nullptr; +} + +X_RESULT TouchInputDriver::GetCapabilities(uint32_t user_index, uint32_t flags, + X_INPUT_CAPABILITIES* out_caps) { + (void)flags; + if (!out_caps) { + return X_ERROR_BAD_ARGUMENTS; + } + if (!RefreshRuntimeModel() || !IsUserSupported(user_index)) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + // Capabilities reflect what the active touch layout can produce, irrespective + // of whether the gameplay overlay is currently visible. Games sometimes query + // capabilities up-front (e.g. at title screen, before the user dismisses any + // launcher chrome and the overlay activates). Gating capabilities on + // gameplay_enabled previously made the touch driver appear disconnected for + // those queries, blocking games from pre-configuring their UI for the + // detected layout. GetState / SetState / GetKeystroke still gate on + // gameplay_enabled so input only flows when the user is actually playing. + std::memset(out_caps, 0, sizeof(*out_caps)); + out_caps->type = XINPUT_DEVTYPE_GAMEPAD; + out_caps->sub_type = XINPUT_DEVSUBTYPE_GAMEPAD; + out_caps->flags = 0; + const auto& controls = runtime_model_->layout().controls; + for (const auto& control : controls) { + ApplyControlCapabilities(control, out_caps); + } + return X_ERROR_SUCCESS; +} + +X_RESULT TouchInputDriver::GetState(uint32_t user_index, + X_INPUT_STATE* out_state) { + if (!out_state) { + return X_ERROR_BAD_ARGUMENTS; + } + if (!RefreshRuntimeModel() || !IsUserSupported(user_index)) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + IOSTouchResolvedState state = runtime_model_->LoadResolvedState(); + if (!state.gameplay_enabled) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + std::memset(out_state, 0, sizeof(*out_state)); + out_state->packet_number = state.packet_number; + out_state->gamepad.buttons = state.buttons; + out_state->gamepad.left_trigger = state.left_trigger; + out_state->gamepad.right_trigger = state.right_trigger; + out_state->gamepad.thumb_lx = state.thumb_lx; + out_state->gamepad.thumb_ly = state.thumb_ly; + out_state->gamepad.thumb_rx = state.thumb_rx; + out_state->gamepad.thumb_ry = state.thumb_ry; + return X_ERROR_SUCCESS; +} + +X_RESULT TouchInputDriver::SetState(uint32_t user_index, + X_INPUT_VIBRATION* vibration) { + if (!vibration) { + return X_ERROR_BAD_ARGUMENTS; + } + if (!RefreshRuntimeModel() || !IsUserSupported(user_index)) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + IOSTouchResolvedState state = runtime_model_->LoadResolvedState(); + return state.gameplay_enabled ? X_ERROR_SUCCESS + : X_ERROR_DEVICE_NOT_CONNECTED; +} + +X_RESULT TouchInputDriver::GetKeystroke(uint32_t user_index, uint32_t flags, + X_INPUT_KEYSTROKE* out_keystroke) { + (void)flags; + if (!out_keystroke) { + return X_ERROR_BAD_ARGUMENTS; + } + if (!RefreshRuntimeModel() || !IsUserSupported(user_index)) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + std::memset(out_keystroke, 0, sizeof(*out_keystroke)); + IOSTouchResolvedState state = runtime_model_->LoadResolvedState(); + std::lock_guard lock(keystroke_mutex_); + if (!state.gameplay_enabled) { + pending_keystrokes_.clear(); + has_keystroke_state_ = false; + last_keystroke_field_ = 0; + return X_ERROR_DEVICE_NOT_CONNECTED; + } + + const uint64_t current_field = TouchKeystrokeFieldFromState(state); + if (!has_keystroke_state_) { + has_keystroke_state_ = true; + last_keystroke_field_ = current_field; + } else if (current_field != last_keystroke_field_) { + const uint64_t changed_bits = current_field ^ last_keystroke_field_; + for (size_t bit_index = 0; bit_index < kTouchVirtualKeys.size(); + ++bit_index) { + const uint64_t bit_mask = uint64_t(1) << bit_index; + if (!(changed_bits & bit_mask)) { + continue; + } + const ui::VirtualKey virtual_key = kTouchVirtualKeys[bit_index]; + if (virtual_key == ui::VirtualKey::kNone) { + continue; + } + X_INPUT_KEYSTROKE keystroke = {}; + keystroke.virtual_key = uint16_t(virtual_key); + keystroke.flags = (current_field & bit_mask) ? X_INPUT_KEYSTROKE_KEYDOWN + : X_INPUT_KEYSTROKE_KEYUP; + keystroke.user_index = + user_index == XUserIndexAny ? 0 : uint8_t(user_index); + pending_keystrokes_.push_back(keystroke); + } + last_keystroke_field_ = current_field; + } + + if (pending_keystrokes_.empty()) { + return X_ERROR_EMPTY; + } + + *out_keystroke = pending_keystrokes_.front(); + pending_keystrokes_.pop_front(); + return X_ERROR_SUCCESS; +} + +InputType TouchInputDriver::GetInputType() const { + return InputType::Controller; +} + +bool TouchInputDriver::IsUserSupported(uint32_t user_index) const { + return user_index == 0 || user_index == XUserIndexAny; +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_input_driver_ios.h b/src/xenia/hid/touch/touch_input_driver_ios.h new file mode 100644 index 000000000..6eb75a570 --- /dev/null +++ b/src/xenia/hid/touch/touch_input_driver_ios.h @@ -0,0 +1,82 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_INPUT_DRIVER_IOS_H_ +#define XENIA_HID_TOUCH_INPUT_DRIVER_IOS_H_ + +#include +#include +#include +#include + +#include "xenia/hid/input_driver.h" + +namespace xe { +namespace hid { +namespace touch { +class IOSTouchRuntimeModel; +} // namespace touch +} // namespace hid +} // namespace xe + +namespace xe { +namespace hid { +namespace touch { + +// HID input driver that surfaces the iOS on-screen touch overlay's resolved +// gamepad state to the emulator. +// +// Lifetime contract for `runtime_model_`: +// * The IOSTouchRuntimeModel is owned by XeniaViewController on the UIKit +// main thread (see ios_main_view_controller.mm) and lives for the +// controller's lifetime, which spans the whole app process. +// * IOSWindowedAppContext::set_touch_runtime_model() is invoked exactly +// once by XeniaAppDelegate after the controller is created. The pointer +// is never re-set or cleared while the driver is alive. +// * RefreshRuntimeModel() lazily caches the pointer on first use; once +// cached it never changes. GetState/GetKeystroke read the resolved-state +// buffer through this pointer; the buffer itself is internally +// mutex-protected, so cross-thread reads are safe. +// * If the controller were ever to outlive the driver, dropping the cached +// pointer to nullptr would also be safe — every consumer re-checks via +// RefreshRuntimeModel(). The reverse (driver outliving the controller) +// would be a use-after-free; in the current architecture it cannot happen +// because the emulator (which owns the driver) is torn down before the +// UIKit hierarchy. +class TouchInputDriver final : public InputDriver { + public: + explicit TouchInputDriver(xe::ui::Window* window, size_t window_z_order); + ~TouchInputDriver() override; + + X_STATUS Setup() override; + + X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, + X_INPUT_CAPABILITIES* out_caps) override; + X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override; + X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override; + X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, + X_INPUT_KEYSTROKE* out_keystroke) override; + InputType GetInputType() const override; + + private: + bool IsUserSupported(uint32_t user_index) const; + bool RefreshRuntimeModel(); + + IOSTouchRuntimeModel* runtime_model_ = nullptr; + std::mutex keystroke_mutex_; + bool has_keystroke_state_ = false; + uint64_t last_keystroke_field_ = 0; + std::deque pending_keystrokes_; +}; + +} // namespace touch +} // namespace hid +} // namespace xe + +#endif // XENIA_HID_TOUCH_INPUT_DRIVER_IOS_H_ diff --git a/src/xenia/hid/touch/touch_input_resolver.cc b/src/xenia/hid/touch/touch_input_resolver.cc new file mode 100644 index 000000000..ce6be7f4c --- /dev/null +++ b/src/xenia/hid/touch/touch_input_resolver.cc @@ -0,0 +1,265 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_input_resolver.h" + +#include +#include + +#include "xenia/hid/input.h" + +namespace xe::hid::touch { +namespace { + +constexpr float kTouchAxisMax = 32767.0f; +constexpr float kTouchComboStickRadiusFraction = 0.32f; +constexpr float kMoveStickDoubleTapMaxSeconds = 0.34f; +constexpr float kMoveStickDoubleTapForwardThreshold = 0.58f; +constexpr float kMoveStickDoubleTapLateralThreshold = 0.58f; + +} // namespace + +IOSTouchPoint ClampTouchLookVector(IOSTouchPoint value) { + return IOSTouchPoint{std::clamp(value.x, -1.0f, 1.0f), + std::clamp(value.y, -1.0f, 1.0f)}; +} + +IOSTouchPoint TouchSwipeLookVectorForDelta(IOSTouchPoint delta, + float look_scale, + float points_per_full_scale, + float vertical_scale) { + const float safe_points_per_full_scale = + std::clamp(points_per_full_scale, 1.0f, 64.0f); + const float clamped_look_scale = std::clamp(look_scale, 0.25f, 4.0f); + const float clamped_vertical_scale = std::clamp(vertical_scale, 0.25f, 4.0f); + return ClampTouchLookVector( + IOSTouchPoint{delta.x / safe_points_per_full_scale * clamped_look_scale, + -delta.y / safe_points_per_full_scale * clamped_look_scale * + clamped_vertical_scale}); +} + +IOSTouchComboSubzone ResolveTouchComboSubzone( + const IOSTouchControlDefinition& control, + const IOSTouchRect& resolved_frame, IOSTouchPoint point) { + if (!control.move_with_dpad_ring || + control.type != IOSTouchControlType::kMoveStick) { + return IOSTouchComboSubzone::kStick; + } + if (resolved_frame.width <= 0.0f || resolved_frame.height <= 0.0f) { + return IOSTouchComboSubzone::kStick; + } + const float centre_x = resolved_frame.x + resolved_frame.width * 0.5f; + const float centre_y = resolved_frame.y + resolved_frame.height * 0.5f; + const float short_side = + std::min(resolved_frame.width, resolved_frame.height); + const float stick_radius = short_side * kTouchComboStickRadiusFraction; + const float dx = point.x - centre_x; + const float dy = point.y - centre_y; + const float distance_squared = dx * dx + dy * dy; + if (distance_squared <= stick_radius * stick_radius) { + return IOSTouchComboSubzone::kStick; + } + if (std::abs(dx) > std::abs(dy)) { + return dx > 0.0f ? IOSTouchComboSubzone::kDpadRight + : IOSTouchComboSubzone::kDpadLeft; + } + return dy > 0.0f ? IOSTouchComboSubzone::kDpadDown + : IOSTouchComboSubzone::kDpadUp; +} + +bool TouchControlContainsPoint(const IOSTouchControlDefinition& control, + const IOSTouchRect& resolved_frame, + IOSTouchPoint point) { + if (!IOSTouchRectContainsPoint(resolved_frame, point)) { + return false; + } + if (control.move_with_dpad_ring && + control.type == IOSTouchControlType::kMoveStick) { + return true; + } + if (control.shape != IOSTouchControlShape::kCircle) { + return true; + } + + const float width = resolved_frame.width; + const float height = resolved_frame.height; + if (width <= 0.0f || height <= 0.0f) { + return false; + } + + const float radius = std::min(width, height) * 0.5f; + if (width >= height) { + if (point.x >= resolved_frame.x + radius && + point.x <= resolved_frame.x + width - radius) { + return true; + } + const float center_y = resolved_frame.y + height * 0.5f; + const float left_center_x = resolved_frame.x + radius; + const float right_center_x = resolved_frame.x + width - radius; + const float left_dx = point.x - left_center_x; + const float right_dx = point.x - right_center_x; + const float dy = point.y - center_y; + return left_dx * left_dx + dy * dy <= radius * radius || + right_dx * right_dx + dy * dy <= radius * radius; + } + + if (point.y >= resolved_frame.y + radius && + point.y <= resolved_frame.y + height - radius) { + return true; + } + const float center_x = resolved_frame.x + width * 0.5f; + const float top_center_y = resolved_frame.y + radius; + const float bottom_center_y = resolved_frame.y + height - radius; + const float dx = point.x - center_x; + const float top_dy = point.y - top_center_y; + const float bottom_dy = point.y - bottom_center_y; + return dx * dx + top_dy * top_dy <= radius * radius || + dx * dx + bottom_dy * bottom_dy <= radius * radius; +} + +int16_t TouchAxisFromUnit(float unit_value) { + const float clamped_value = std::clamp(unit_value, -1.0f, 1.0f); + return static_cast(std::lround(clamped_value * kTouchAxisMax)); +} + +bool TouchStatesEqualIgnoringPacket(const IOSTouchResolvedState& left, + const IOSTouchResolvedState& right) { + return left.buttons == right.buttons && + left.left_trigger == right.left_trigger && + left.right_trigger == right.right_trigger && + left.thumb_lx == right.thumb_lx && left.thumb_ly == right.thumb_ly && + left.thumb_rx == right.thumb_rx && left.thumb_ry == right.thumb_ry && + left.gameplay_enabled == right.gameplay_enabled && + left.pause_requested == right.pause_requested; +} + +void ApplyTouchActionMapping(const IOSTouchControlDefinition& control, + IOSTouchResolvedState* state) { + if (!state) { + return; + } + state->buttons |= control.mapped_buttons; + state->left_trigger = + std::max(state->left_trigger, control.mapped_left_trigger); + state->right_trigger = + std::max(state->right_trigger, control.mapped_right_trigger); +} + +void ApplyTouchActionMappingForAction(IOSTouchAction action, + IOSTouchResolvedState* state) { + IOSTouchControlDefinition control; + ConfigureIOSTouchControlAction(action, &control); + ApplyTouchActionMapping(control, state); +} + +bool TouchInteractionBehaviorConfigured( + const IOSTouchInteractionBehavior& behavior) { + return behavior.trigger != IOSTouchInteractionTrigger::kNone && + (behavior.action != IOSTouchAction::kNone || + behavior.enables_relative_look); +} + +IOSTouchInteractionBehaviorState ResolveTouchInteractionBehaviorState( + const IOSTouchInteractionBehavior& behavior, + const IOSTouchInputCapture& capture, double current_time) { + IOSTouchInteractionBehaviorState state; + if (!TouchInteractionBehaviorConfigured(behavior) || + capture.began_time <= 0.0) { + return state; + } + + const float elapsed_seconds = + static_cast(current_time - capture.began_time); + switch (behavior.trigger) { + case IOSTouchInteractionTrigger::kHold: { + const float hold_seconds = std::clamp(behavior.hold_seconds, 0.05f, 1.0f); + if (elapsed_seconds < hold_seconds) { + return state; + } + } break; + case IOSTouchInteractionTrigger::kHoldDrag: { + const float hold_seconds = std::clamp(behavior.hold_seconds, 0.05f, 1.0f); + if (elapsed_seconds < hold_seconds) { + return state; + } + const float drag_distance = + std::hypot(capture.current_point.x - capture.anchor_point.x, + capture.current_point.y - capture.anchor_point.y); + if (drag_distance < + std::clamp(behavior.drag_threshold_points, 2.0f, 96.0f)) { + return state; + } + } break; + case IOSTouchInteractionTrigger::kDoubleTap: + case IOSTouchInteractionTrigger::kDoubleTapForward: + case IOSTouchInteractionTrigger::kNone: + default: + return state; + } + + state.active = true; + state.enables_relative_look = behavior.enables_relative_look; + state.relative_look_scale = + std::clamp(behavior.relative_look_scale, 0.1f, 2.0f); + return state; +} + +bool TouchControlUsesDeferredPrimaryTap( + const IOSTouchControlDefinition& control) { + return control.type == IOSTouchControlType::kActionButton && + !control.hold_while_captured && + (control.secondary_behavior.trigger == + IOSTouchInteractionTrigger::kHold || + control.secondary_behavior.trigger == + IOSTouchInteractionTrigger::kHoldDrag) && + TouchInteractionBehaviorConfigured(control.secondary_behavior); +} + +IOSTouchPoint MoveStickUnitVectorForCapture( + const IOSTouchControlDefinition& control, const IOSTouchRect& frame, + const IOSTouchInputCapture& capture) { + const float outer_radius = std::min(frame.width, frame.height) * + std::max(control.activation_radius, 0.24f); + IOSTouchPoint delta{capture.current_point.x - capture.anchor_point.x, + capture.current_point.y - capture.anchor_point.y}; + const float distance = std::hypot(delta.x, delta.y); + if (distance > outer_radius && distance > 0.0f) { + const float scale = outer_radius / distance; + delta.x *= scale; + delta.y *= scale; + } + + float normalized_x = outer_radius > 0.0f ? delta.x / outer_radius : 0.0f; + float normalized_y = outer_radius > 0.0f ? delta.y / outer_radius : 0.0f; + const float magnitude = + std::sqrt(normalized_x * normalized_x + normalized_y * normalized_y); + if (magnitude < control.deadzone || magnitude <= 0.0f) { + return IOSTouchPoint{}; + } + const float rescaled_magnitude = std::clamp( + (magnitude - control.deadzone) / (1.0f - control.deadzone), 0.0f, 1.0f); + normalized_x = normalized_x / magnitude * rescaled_magnitude; + normalized_y = normalized_y / magnitude * rescaled_magnitude; + return IOSTouchPoint{normalized_x, normalized_y}; +} + +bool MoveStickCaptureQualifiesForDoubleTapForward( + const IOSTouchControlDefinition& control, const IOSTouchRect& frame, + const IOSTouchInputCapture& capture, double current_time) { + if (capture.began_time <= 0.0 || + (current_time - capture.began_time) > kMoveStickDoubleTapMaxSeconds) { + return false; + } + const IOSTouchPoint unit = + MoveStickUnitVectorForCapture(control, frame, capture); + return unit.y <= -kMoveStickDoubleTapForwardThreshold && + std::abs(unit.x) <= kMoveStickDoubleTapLateralThreshold; +} + +} // namespace xe::hid::touch diff --git a/src/xenia/hid/touch/touch_input_resolver.h b/src/xenia/hid/touch/touch_input_resolver.h new file mode 100644 index 000000000..61a3ae265 --- /dev/null +++ b/src/xenia/hid/touch/touch_input_resolver.h @@ -0,0 +1,76 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_TOUCH_INPUT_RESOLVER_H_ +#define XENIA_HID_TOUCH_TOUCH_INPUT_RESOLVER_H_ + +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe::hid::touch { + +enum class IOSTouchComboSubzone : uint8_t { + kNone = 0, + kStick, + kDpadUp, + kDpadDown, + kDpadLeft, + kDpadRight, +}; + +struct IOSTouchInputCapture { + IOSTouchPoint anchor_point; + IOSTouchPoint current_point; + double began_time = 0.0; + bool secondary_behavior_triggered = false; + IOSTouchComboSubzone combo_subzone = IOSTouchComboSubzone::kNone; +}; + +struct IOSTouchInteractionBehaviorState { + bool active = false; + bool enables_relative_look = false; + float relative_look_scale = 1.0f; +}; + +IOSTouchPoint ClampTouchLookVector(IOSTouchPoint value); +IOSTouchPoint TouchSwipeLookVectorForDelta(IOSTouchPoint delta, + float look_scale, + float points_per_full_scale, + float vertical_scale); +IOSTouchComboSubzone ResolveTouchComboSubzone( + const IOSTouchControlDefinition& control, + const IOSTouchRect& resolved_frame, IOSTouchPoint point); +bool TouchControlContainsPoint(const IOSTouchControlDefinition& control, + const IOSTouchRect& resolved_frame, + IOSTouchPoint point); +int16_t TouchAxisFromUnit(float unit_value); +bool TouchStatesEqualIgnoringPacket(const IOSTouchResolvedState& left, + const IOSTouchResolvedState& right); +void ApplyTouchActionMapping(const IOSTouchControlDefinition& control, + IOSTouchResolvedState* state); +void ApplyTouchActionMappingForAction(IOSTouchAction action, + IOSTouchResolvedState* state); +bool TouchInteractionBehaviorConfigured( + const IOSTouchInteractionBehavior& behavior); +IOSTouchInteractionBehaviorState ResolveTouchInteractionBehaviorState( + const IOSTouchInteractionBehavior& behavior, + const IOSTouchInputCapture& capture, double current_time); +bool TouchControlUsesDeferredPrimaryTap( + const IOSTouchControlDefinition& control); +IOSTouchPoint MoveStickUnitVectorForCapture( + const IOSTouchControlDefinition& control, const IOSTouchRect& frame, + const IOSTouchInputCapture& capture); +bool MoveStickCaptureQualifiesForDoubleTapForward( + const IOSTouchControlDefinition& control, const IOSTouchRect& frame, + const IOSTouchInputCapture& capture, double current_time); + +} // namespace xe::hid::touch + +#endif // XENIA_HID_TOUCH_TOUCH_INPUT_RESOLVER_H_ diff --git a/src/xenia/hid/touch/touch_layout_editor.cc b/src/xenia/hid/touch/touch_layout_editor.cc new file mode 100644 index 000000000..f0387ccc7 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_editor.cc @@ -0,0 +1,237 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_editor.h" + +#include +#include + +namespace xe { +namespace hid { +namespace touch { +namespace { + +float MaxNormalizedEditorControlSize(IOSTouchControlType control_type) { + return control_type == IOSTouchControlType::kLookSwipeZone ? 1.0f : 0.98f; +} + +} // namespace + +bool IOSTouchLayoutHasActionBinding(const IOSTouchLayoutModel& layout, + IOSTouchAction action) { + return std::any_of(layout.controls.begin(), layout.controls.end(), + [action](const IOSTouchControlDefinition& control) { + return control.type == + IOSTouchControlType::kActionButton && + control.action == action; + }); +} + +std::string MakeUniqueIOSTouchActionButtonIdentifier( + const IOSTouchLayoutModel& layout) { + int suffix = 1; + while (true) { + std::string identifier = suffix == 1 + ? "action_button" + : ("action_button_" + std::to_string(suffix)); + const bool exists = + std::any_of(layout.controls.begin(), layout.controls.end(), + [&identifier](const IOSTouchControlDefinition& control) { + return control.identifier == identifier; + }); + if (!exists) { + return identifier; + } + ++suffix; + } +} + +IOSTouchAction SuggestedNewIOSTouchActionButtonBinding( + const IOSTouchLayoutModel& layout) { + const IOSTouchAction kSuggestedBindings[] = { + IOSTouchAction::kButtonB, IOSTouchAction::kButtonY, + IOSTouchAction::kLeftBumper, IOSTouchAction::kRightBumper, + IOSTouchAction::kButtonA, IOSTouchAction::kButtonX, + IOSTouchAction::kLeftTrigger, IOSTouchAction::kRightTrigger, + IOSTouchAction::kBack, IOSTouchAction::kStart, + IOSTouchAction::kLeftThumb, IOSTouchAction::kRightThumb, + IOSTouchAction::kDpadUp, IOSTouchAction::kDpadDown, + IOSTouchAction::kDpadLeft, IOSTouchAction::kDpadRight, + }; + for (IOSTouchAction binding : kSuggestedBindings) { + if (!IOSTouchLayoutHasActionBinding(layout, binding)) { + return binding; + } + } + return IOSTouchAction::kButtonA; +} + +IOSTouchRect SuggestedNewIOSTouchActionButtonFrame(IOSTouchAction action) { + switch (action) { + case IOSTouchAction::kButtonB: + return IOSTouchRect{0.88f, 0.66f, 0.11f, 0.13f}; + case IOSTouchAction::kButtonY: + return IOSTouchRect{0.84f, 0.36f, 0.11f, 0.13f}; + case IOSTouchAction::kLeftBumper: + return IOSTouchRect{0.58f, 0.49f, 0.11f, 0.13f}; + case IOSTouchAction::kRightBumper: + return IOSTouchRect{0.72f, 0.31f, 0.11f, 0.13f}; + case IOSTouchAction::kBack: + return IOSTouchRect{0.44f, 0.03f, 0.10f, 0.10f}; + case IOSTouchAction::kStart: + return IOSTouchRect{0.56f, 0.03f, 0.10f, 0.10f}; + case IOSTouchAction::kLeftThumb: + return IOSTouchRect{0.08f, 0.79f, 0.11f, 0.13f}; + case IOSTouchAction::kRightThumb: + return IOSTouchRect{0.84f, 0.84f, 0.11f, 0.13f}; + case IOSTouchAction::kDpadUp: + return IOSTouchRect{0.24f, 0.22f, 0.10f, 0.12f}; + case IOSTouchAction::kDpadDown: + return IOSTouchRect{0.24f, 0.36f, 0.10f, 0.12f}; + case IOSTouchAction::kDpadLeft: + return IOSTouchRect{0.17f, 0.29f, 0.10f, 0.12f}; + case IOSTouchAction::kDpadRight: + return IOSTouchRect{0.31f, 0.29f, 0.10f, 0.12f}; + case IOSTouchAction::kButtonA: + return IOSTouchRect{0.86f, 0.54f, 0.13f, 0.14f}; + case IOSTouchAction::kButtonX: + return IOSTouchRect{0.72f, 0.47f, 0.13f, 0.14f}; + case IOSTouchAction::kLeftTrigger: + return IOSTouchRect{0.60f, 0.67f, 0.13f, 0.14f}; + case IOSTouchAction::kRightTrigger: + return IOSTouchRect{0.75f, 0.74f, 0.23f, 0.17f}; + case IOSTouchAction::kNone: + case IOSTouchAction::kMove: + case IOSTouchAction::kLook: + case IOSTouchAction::kPauseMenu: + default: + return IOSTouchRect{0.80f, 0.40f, 0.12f, 0.14f}; + } +} + +IOSTouchRect ClampIOSTouchEditorControlFrame(const IOSTouchRect& rect, + IOSTouchControlType control_type) { + IOSTouchRect result = rect; + const float max_control_size = MaxNormalizedEditorControlSize(control_type); + result.width = std::clamp(result.width, 0.05f, max_control_size); + result.height = std::clamp(result.height, 0.05f, max_control_size); + result.x = std::clamp(result.x, 0.0f, 1.0f - result.width); + result.y = std::clamp(result.y, 0.0f, 1.0f - result.height); + return result; +} + +bool AddSuggestedActionButtonToIOSTouchLayout( + IOSTouchLayoutModel* layout, bool is_portrait, + std::string* selected_identifier_out) { + if (!layout || layout->controls.size() >= kMaxIOSTouchControls) { + return false; + } + + const IOSTouchAction action = + SuggestedNewIOSTouchActionButtonBinding(*layout); + IOSTouchControlDefinition control; + control.identifier = MakeUniqueIOSTouchActionButtonIdentifier(*layout); + control.type = IOSTouchControlType::kActionButton; + control.shape = IOSTouchControlShape::kCircle; + control.normalized_frame = ClampIOSTouchEditorControlFrame( + SuggestedNewIOSTouchActionButtonFrame(action), control.type); + if (is_portrait) { + control.has_portrait_frame = true; + control.portrait_normalized_frame = control.normalized_frame; + } + control.activation_radius = 0.5f; + control.visual_opacity = 0.92f; + control.capture_priority = 232; + ConfigureIOSTouchControlAction(action, &control); + const std::string selected_identifier = control.identifier; + layout->controls.push_back(std::move(control)); + if (selected_identifier_out) { + *selected_identifier_out = selected_identifier; + } + return true; +} + +bool MirrorIOSTouchLayoutControlHorizontally(IOSTouchLayoutModel* layout, + std::size_t control_index, + bool is_portrait) { + if (!layout || control_index >= layout->controls.size()) { + return false; + } + + IOSTouchControlDefinition& control = layout->controls[control_index]; + IOSTouchRect& active_frame = + MutableActiveControlFrameForOrientation(control, is_portrait); + active_frame.x = + std::clamp(1.0f - active_frame.x - active_frame.width, 0.0f, 1.0f); + return true; +} + +bool CopyIOSTouchLayoutFramesAcrossOrientations(IOSTouchLayoutModel* layout, + bool from_landscape) { + if (!layout || layout->controls.empty()) { + return false; + } + + bool any_changed = false; + for (auto& control : layout->controls) { + if (from_landscape) { + control.portrait_normalized_frame = control.normalized_frame; + control.has_portrait_frame = true; + any_changed = true; + } else if (control.has_portrait_frame) { + control.normalized_frame = control.portrait_normalized_frame; + any_changed = true; + } + } + return any_changed; +} + +bool DuplicateIOSTouchLayoutActionButton(IOSTouchLayoutModel* layout, + std::size_t source_control_index, + bool is_portrait, + std::string* selected_identifier_out) { + if (!layout || source_control_index >= layout->controls.size() || + layout->controls.size() >= kMaxIOSTouchControls) { + return false; + } + + const auto& source_control = layout->controls[source_control_index]; + if (source_control.type != IOSTouchControlType::kActionButton) { + return false; + } + + IOSTouchControlDefinition duplicate = source_control; + duplicate.identifier = MakeUniqueIOSTouchActionButtonIdentifier(*layout); + IOSTouchRect& active_frame = + MutableActiveControlFrameForOrientation(duplicate, is_portrait); + active_frame = ClampIOSTouchEditorControlFrame( + IOSTouchRect{active_frame.x + 0.03f, active_frame.y + 0.03f, + active_frame.width, active_frame.height}, + duplicate.type); + const std::string selected_identifier = duplicate.identifier; + layout->controls.push_back(std::move(duplicate)); + if (selected_identifier_out) { + *selected_identifier_out = selected_identifier; + } + return true; +} + +bool DeleteIOSTouchLayoutControl(IOSTouchLayoutModel* layout, + std::size_t control_index) { + if (!layout || control_index >= layout->controls.size() || + layout->controls.size() <= 1) { + return false; + } + layout->controls.erase(layout->controls.begin() + control_index); + return true; +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_layout_editor.h b/src/xenia/hid/touch/touch_layout_editor.h new file mode 100644 index 000000000..49f448862 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_editor.h @@ -0,0 +1,50 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_LAYOUT_EDITOR_H_ +#define XENIA_HID_TOUCH_LAYOUT_EDITOR_H_ + +#include +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace hid { +namespace touch { + +bool IOSTouchLayoutHasActionBinding(const IOSTouchLayoutModel& layout, + IOSTouchAction action); +std::string MakeUniqueIOSTouchActionButtonIdentifier( + const IOSTouchLayoutModel& layout); +IOSTouchAction SuggestedNewIOSTouchActionButtonBinding( + const IOSTouchLayoutModel& layout); +IOSTouchRect SuggestedNewIOSTouchActionButtonFrame(IOSTouchAction action); +IOSTouchRect ClampIOSTouchEditorControlFrame(const IOSTouchRect& rect, + IOSTouchControlType control_type); +bool AddSuggestedActionButtonToIOSTouchLayout( + IOSTouchLayoutModel* layout, bool is_portrait, + std::string* selected_identifier_out); +bool MirrorIOSTouchLayoutControlHorizontally(IOSTouchLayoutModel* layout, + std::size_t control_index, + bool is_portrait); +bool CopyIOSTouchLayoutFramesAcrossOrientations(IOSTouchLayoutModel* layout, + bool from_landscape); +bool DuplicateIOSTouchLayoutActionButton(IOSTouchLayoutModel* layout, + std::size_t source_control_index, + bool is_portrait, + std::string* selected_identifier_out); +bool DeleteIOSTouchLayoutControl(IOSTouchLayoutModel* layout, + std::size_t control_index); + +} // namespace touch +} // namespace hid +} // namespace xe + +#endif // XENIA_HID_TOUCH_LAYOUT_EDITOR_H_ diff --git a/src/xenia/hid/touch/touch_layout_ios.cc b/src/xenia/hid/touch/touch_layout_ios.cc new file mode 100644 index 000000000..2d5efd6d9 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios.cc @@ -0,0 +1,10 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios.h" diff --git a/src/xenia/hid/touch/touch_layout_ios.h b/src/xenia/hid/touch/touch_layout_ios.h new file mode 100644 index 000000000..5b755838b --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios.h @@ -0,0 +1,297 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_LAYOUT_IOS_H_ +#define XENIA_HID_TOUCH_LAYOUT_IOS_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/tomlplusplus/toml.hpp" +#include "xenia/hid/input.h" + +namespace xe { +namespace hid { +namespace touch { + +enum class IOSTouchControlType : uint8_t { + kMoveStick = 0, + kLookSwipeZone, + kActionButton, + kPauseButton, +}; + +enum class IOSTouchControlShape : uint8_t { + kCircle = 0, + kRoundedRect, +}; + +enum class IOSTouchTintStyle : uint8_t { + kAuto = 0, + kAmber, + kSky, + kMint, + kRose, + kLime, + kCoral, + kSlate, +}; + +enum class IOSTouchAction : uint8_t { + kNone = 0, + kMove, + kLook, + kPauseMenu, + kButtonA, + kButtonB, + kButtonX, + kButtonY, + kLeftBumper, + kRightBumper, + kLeftTrigger, + kRightTrigger, + kBack, + kStart, + kLeftThumb, + kRightThumb, + kDpadUp, + kDpadDown, + kDpadLeft, + kDpadRight, + kJump = kButtonA, + kReloadInteract = kButtonX, + kAim = kLeftTrigger, + kFire = kRightTrigger, +}; + +enum class IOSTouchInteractionTrigger : uint8_t { + kNone = 0, + kHold, + kHoldDrag, + kDoubleTap, + kDoubleTapForward, +}; + +inline constexpr std::array kIOSTouchEditableActions = { + IOSTouchAction::kButtonA, IOSTouchAction::kButtonB, + IOSTouchAction::kButtonX, IOSTouchAction::kButtonY, + IOSTouchAction::kLeftBumper, IOSTouchAction::kRightBumper, + IOSTouchAction::kLeftTrigger, IOSTouchAction::kRightTrigger, + IOSTouchAction::kBack, IOSTouchAction::kStart, + IOSTouchAction::kLeftThumb, IOSTouchAction::kRightThumb, + IOSTouchAction::kDpadUp, IOSTouchAction::kDpadDown, + IOSTouchAction::kDpadLeft, IOSTouchAction::kDpadRight, + IOSTouchAction::kNone, +}; + +inline constexpr std::array kIOSTouchEditableTintStyles = + { + IOSTouchTintStyle::kAuto, IOSTouchTintStyle::kAmber, + IOSTouchTintStyle::kSky, IOSTouchTintStyle::kMint, + IOSTouchTintStyle::kRose, IOSTouchTintStyle::kLime, + IOSTouchTintStyle::kCoral, IOSTouchTintStyle::kSlate, +}; + +inline constexpr std::size_t kMaxIOSTouchControls = 32; + +struct IOSTouchPoint { + float x = 0.0f; + float y = 0.0f; +}; + +struct IOSTouchRect { + float x = 0.0f; + float y = 0.0f; + float width = 0.0f; + float height = 0.0f; +}; + +struct IOSTouchLayoutSpace { + float origin_x = 0.0f; + float origin_y = 0.0f; + float width = 0.0f; + float height = 0.0f; + + bool IsEmpty() const { return width <= 0.0f || height <= 0.0f; } +}; + +struct IOSTouchInteractionBehavior { + IOSTouchInteractionTrigger trigger = IOSTouchInteractionTrigger::kNone; + IOSTouchAction action = IOSTouchAction::kNone; + bool enables_relative_look = false; + float relative_look_scale = 1.0f; + float hold_seconds = 0.30f; + float drag_threshold_points = 14.0f; +}; + +struct IOSTouchControlDefinition { + std::string identifier; + std::string label; + bool label_uses_default = true; + bool label_hidden = false; + IOSTouchControlType type = IOSTouchControlType::kActionButton; + IOSTouchAction action = IOSTouchAction::kNone; + IOSTouchControlShape shape = IOSTouchControlShape::kCircle; + IOSTouchRect normalized_frame; + // Optional portrait variant. When unset (`has_portrait_frame == false`), + // `normalized_frame` is used in both orientations — preserving the legacy + // single-frame behaviour for layouts authored before the per-orientation + // split. When set, `portrait_normalized_frame` overrides `normalized_frame` + // whenever the host view is rendered in portrait orientation, so a single + // layout can hold separate landscape and portrait positions for the same + // control without one orientation squashing the other. + bool has_portrait_frame = false; + IOSTouchRect portrait_normalized_frame; + float deadzone = 0.0f; + float activation_radius = 0.0f; + float visual_opacity = 1.0f; + IOSTouchTintStyle tint_style = IOSTouchTintStyle::kAuto; + bool hold_while_captured = false; + bool enables_relative_look = false; + // Per-control look sensitivity multiplier, exposed in the touch editor. + // For Look swipe zones: scales the swipe-points-per-full-deflection so + // that 2.0x means full look output is reached with half as much swipe + // (snappier aim) and 0.5x means twice as much swipe is needed. + // For action buttons with `enables_relative_look`: scales the relative-look + // output emitted while the button is held. + // Effective editor-clamped range: 0.25 .. 4.0. + float relative_look_scale = 1.0f; + // Move + D-Pad combo: when set on a kMoveStick, the control renders four + // tappable D-Pad arrows around the stick base and the publish path emits + // the corresponding D-Pad bit (Up/Down/Left/Right) while a finger is held + // on an arrow. The centre area still drives the stick (analog left thumb, + // or right thumb if `action == kLook`). Lets a single combo control replace + // a separate Move stick + dedicated D-Pad cross in a layout — common for + // Xbox 360 games that use both for movement and quick item swap. + bool move_with_dpad_ring = false; + IOSTouchInteractionBehavior secondary_behavior; + uint8_t capture_priority = 0; + uint16_t mapped_buttons = 0; + uint8_t mapped_left_trigger = 0; + uint8_t mapped_right_trigger = 0; +}; + +struct IOSTouchLayoutModel { + std::string layout_id; + std::string display_name; + std::string author; + std::string base_template; + std::vector controls; +}; + +struct IOSTouchResolvedState { + uint32_t packet_number = 0; + uint16_t buttons = 0; + uint8_t left_trigger = 0; + uint8_t right_trigger = 0; + int16_t thumb_lx = 0; + int16_t thumb_ly = 0; + int16_t thumb_rx = 0; + int16_t thumb_ry = 0; + bool gameplay_enabled = false; + bool pause_requested = false; +}; + +static_assert(std::is_trivially_copyable::value, + "IOSTouchResolvedState must remain trivially copyable"); + +class IOSTouchResolvedStateBuffer { + public: + IOSTouchResolvedStateBuffer() = default; + + void Store(const IOSTouchResolvedState& state); + IOSTouchResolvedState Load() const; + + private: + mutable std::mutex mutex_; + IOSTouchResolvedState state_{}; +}; + +class IOSTouchRuntimeModel { + public: + IOSTouchRuntimeModel(); + + // Layout mutation/reads are expected on the UIKit main thread. Cross-thread + // consumers should use resolved-state snapshots rather than touching layout. + const IOSTouchLayoutModel& layout() const { return layout_; } + IOSTouchLayoutModel& mutable_layout() { return layout_; } + void SetLayout(IOSTouchLayoutModel layout); + + void StoreResolvedState(const IOSTouchResolvedState& state); + IOSTouchResolvedState LoadResolvedState() const; + void ResetResolvedState(); + + private: + IOSTouchLayoutModel layout_; + IOSTouchResolvedStateBuffer resolved_state_; +}; + +IOSTouchLayoutModel CreateDefaultIOSFPSLayoutModel(); +IOSTouchControlDefinition CreateDefaultIOSTouchControlDefinition( + IOSTouchControlType type); +toml::table EncodeIOSTouchLayoutModel(const IOSTouchLayoutModel& layout); +bool ApplyIOSTouchLayoutModel(const toml::table& table, + IOSTouchLayoutModel* layout); +bool IsEditableIOSTouchAction(IOSTouchAction action); +bool IsSupportedIOSTouchPrimaryAction(IOSTouchControlType control_type, + IOSTouchAction action); +const char* IOSTouchActionDisplayName(IOSTouchAction action); +const char* IOSTouchInteractionTriggerDisplayName( + IOSTouchInteractionTrigger trigger); +float DefaultIOSTouchHoldSecondsForInteractionTrigger( + IOSTouchInteractionTrigger trigger); +bool IOSTouchControlHasCustomLabel(const IOSTouchControlDefinition& control); +std::string IOSTouchConfiguredControlLabel( + const IOSTouchControlDefinition& control); +std::string IOSTouchVisibleControlLabel( + const IOSTouchControlDefinition& control); +void SetIOSTouchControlCustomLabel(std::string label, + IOSTouchControlDefinition* control); +void ResetIOSTouchControlLabel(IOSTouchControlDefinition* control); +bool ConfigureIOSTouchControlAction(IOSTouchAction action, + IOSTouchControlDefinition* control); +bool IsValidIOSTouchLayoutModel(const IOSTouchLayoutModel& layout); +IOSTouchAction NextEditableIOSTouchAction(IOSTouchAction action); +const char* IOSTouchTintStyleDisplayName(IOSTouchTintStyle tint_style); +IOSTouchTintStyle NextIOSTouchTintStyle(IOSTouchTintStyle tint_style); +IOSTouchRect ResolveIOSTouchRect(const IOSTouchRect& normalized_rect, + const IOSTouchLayoutSpace& safe_area); +bool IOSTouchRectContainsPoint(const IOSTouchRect& rect, + const IOSTouchPoint& point); + +// Returns the normalized frame the engine should use for the given +// orientation. In portrait, falls through to the landscape `normalized_frame` +// whenever the control has not yet authored a portrait override +// (`has_portrait_frame == false`). Used by every read site (layout +// resolution, snap target gathering, hit-test fallback, capture seeding) so +// orientation switching becomes a single choke point. +const IOSTouchRect& ActiveControlFrameForOrientation( + const IOSTouchControlDefinition& control, bool is_portrait); + +// Mutable counterpart used by editor mutation paths (drag commit, pinch +// resize, mirror, size adjust, duplicate, etc.). When the device is in +// portrait orientation and the control has no portrait frame yet +// (`has_portrait_frame == false`), this lazily promotes the existing +// landscape `normalized_frame` into `portrait_normalized_frame` and sets +// `has_portrait_frame = true`. The first portrait edit therefore starts +// from the landscape baseline rather than (0, 0), which is what makes the +// rotate-then-nudge editor flow feel right. +IOSTouchRect& MutableActiveControlFrameForOrientation( + IOSTouchControlDefinition& control, bool is_portrait); + +} // namespace touch +} // namespace hid +} // namespace xe + +#endif // XENIA_HID_TOUCH_LAYOUT_IOS_H_ diff --git a/src/xenia/hid/touch/touch_layout_ios_codec.cc b/src/xenia/hid/touch/touch_layout_ios_codec.cc new file mode 100644 index 000000000..207637f7e --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_codec.cc @@ -0,0 +1,786 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios_internal.h" + +#include +#include +#include +#include +#include + +namespace xe { +namespace hid { +namespace touch { + +namespace { + +constexpr float kMinControlSize = 0.05f; +constexpr float kMaxControlSize = 0.98f; +constexpr float kLegacyTouchBehaviorHoldSeconds = 0.18f; +constexpr int64_t kCurrentTouchLayoutSchemaVersion = 4; +constexpr size_t kMaxTouchControlIdentifierLength = 64; + +bool UsesLegacyTouchBehaviorHoldDefault(float hold_seconds) { + return std::abs(hold_seconds - kLegacyTouchBehaviorHoldSeconds) < 0.001f; +} + +const char* IOSTouchControlTypeConfigName(IOSTouchControlType type) { + switch (type) { + case IOSTouchControlType::kMoveStick: + return "move_stick"; + case IOSTouchControlType::kLookSwipeZone: + return "look_zone"; + case IOSTouchControlType::kActionButton: + return "action_button"; + case IOSTouchControlType::kPauseButton: + return "pause_button"; + } + return "action_button"; +} + +bool ParseIOSTouchControlTypeConfigName(const std::string& value, + IOSTouchControlType* type_out) { + if (!type_out) { + return false; + } + if (value == "move_stick") { + *type_out = IOSTouchControlType::kMoveStick; + return true; + } + if (value == "look_zone") { + *type_out = IOSTouchControlType::kLookSwipeZone; + return true; + } + if (value == "pause_button") { + *type_out = IOSTouchControlType::kPauseButton; + return true; + } + if (value == "action_button") { + *type_out = IOSTouchControlType::kActionButton; + return true; + } + return false; +} + +const char* IOSTouchControlShapeConfigName(IOSTouchControlShape shape) { + switch (shape) { + case IOSTouchControlShape::kCircle: + return "circle"; + case IOSTouchControlShape::kRoundedRect: + return "rounded_rect"; + } + return "circle"; +} + +bool ParseIOSTouchControlShapeConfigName(const std::string& value, + IOSTouchControlShape* shape_out) { + if (!shape_out) { + return false; + } + if (value == "circle") { + *shape_out = IOSTouchControlShape::kCircle; + return true; + } + if (value == "rounded_rect") { + *shape_out = IOSTouchControlShape::kRoundedRect; + return true; + } + return false; +} + +const char* IOSTouchActionConfigName(IOSTouchAction action) { + switch (action) { + case IOSTouchAction::kNone: + return "none"; + case IOSTouchAction::kMove: + return "move"; + case IOSTouchAction::kLook: + return "look"; + case IOSTouchAction::kPauseMenu: + return "pause"; + case IOSTouchAction::kButtonA: + return "a"; + case IOSTouchAction::kButtonB: + return "b"; + case IOSTouchAction::kButtonX: + return "x"; + case IOSTouchAction::kButtonY: + return "y"; + case IOSTouchAction::kLeftBumper: + return "lb"; + case IOSTouchAction::kRightBumper: + return "rb"; + case IOSTouchAction::kLeftTrigger: + return "lt"; + case IOSTouchAction::kRightTrigger: + return "rt"; + case IOSTouchAction::kBack: + return "back"; + case IOSTouchAction::kStart: + return "start"; + case IOSTouchAction::kLeftThumb: + return "ls"; + case IOSTouchAction::kRightThumb: + return "rs"; + case IOSTouchAction::kDpadUp: + return "dpad_up"; + case IOSTouchAction::kDpadDown: + return "dpad_down"; + case IOSTouchAction::kDpadLeft: + return "dpad_left"; + case IOSTouchAction::kDpadRight: + return "dpad_right"; + } + return "none"; +} + +bool ParseIOSTouchActionConfigName(const std::string& value, + IOSTouchAction* action_out) { + if (!action_out) { + return false; + } + if (value == "move") { + *action_out = IOSTouchAction::kMove; + return true; + } + if (value == "look") { + *action_out = IOSTouchAction::kLook; + return true; + } + if (value == "pause") { + *action_out = IOSTouchAction::kPauseMenu; + return true; + } + if (value == "jump") { + *action_out = IOSTouchAction::kButtonA; + return true; + } + if (value == "reload_interact") { + *action_out = IOSTouchAction::kButtonX; + return true; + } + if (value == "aim") { + *action_out = IOSTouchAction::kLeftTrigger; + return true; + } + if (value == "fire") { + *action_out = IOSTouchAction::kRightTrigger; + return true; + } + for (IOSTouchAction action : kIOSTouchEditableActions) { + if (value == IOSTouchActionConfigName(action)) { + *action_out = action; + return true; + } + } + return false; +} + +const char* IOSTouchInteractionTriggerConfigName( + IOSTouchInteractionTrigger trigger) { + switch (trigger) { + case IOSTouchInteractionTrigger::kNone: + return "none"; + case IOSTouchInteractionTrigger::kHold: + return "hold"; + case IOSTouchInteractionTrigger::kHoldDrag: + return "hold_drag"; + case IOSTouchInteractionTrigger::kDoubleTap: + return "double_tap"; + case IOSTouchInteractionTrigger::kDoubleTapForward: + return "double_tap_forward"; + } + return "none"; +} + +bool ParseIOSTouchInteractionTriggerConfigName( + const std::string& value, IOSTouchInteractionTrigger* trigger_out) { + if (!trigger_out) { + return false; + } + if (value == "none") { + *trigger_out = IOSTouchInteractionTrigger::kNone; + return true; + } + if (value == "hold") { + *trigger_out = IOSTouchInteractionTrigger::kHold; + return true; + } + if (value == "hold_drag") { + *trigger_out = IOSTouchInteractionTrigger::kHoldDrag; + return true; + } + if (value == "double_tap") { + *trigger_out = IOSTouchInteractionTrigger::kDoubleTap; + return true; + } + if (value == "double_tap_forward") { + *trigger_out = IOSTouchInteractionTrigger::kDoubleTapForward; + return true; + } + return false; +} + +const char* IOSTouchTintStyleConfigName(IOSTouchTintStyle tint_style) { + switch (tint_style) { + case IOSTouchTintStyle::kAuto: + return "auto"; + case IOSTouchTintStyle::kAmber: + return "amber"; + case IOSTouchTintStyle::kSky: + return "sky"; + case IOSTouchTintStyle::kMint: + return "mint"; + case IOSTouchTintStyle::kRose: + return "rose"; + case IOSTouchTintStyle::kLime: + return "lime"; + case IOSTouchTintStyle::kCoral: + return "coral"; + case IOSTouchTintStyle::kSlate: + return "slate"; + } + return "auto"; +} + +bool ParseIOSTouchTintStyleConfigName(const std::string& value, + IOSTouchTintStyle* tint_style_out) { + if (!tint_style_out) { + return false; + } + for (IOSTouchTintStyle tint_style : kIOSTouchEditableTintStyles) { + if (value == IOSTouchTintStyleConfigName(tint_style)) { + *tint_style_out = tint_style; + return true; + } + } + return false; +} + +void ApplyDerivedTouchControlDefaults(IOSTouchControlDefinition* control) { + if (!control) { + return; + } + + if (control->type == IOSTouchControlType::kMoveStick && + control->secondary_behavior.trigger == + IOSTouchInteractionTrigger::kDoubleTapForward && + control->secondary_behavior.action == IOSTouchAction::kNone) { + control->secondary_behavior.action = IOSTouchAction::kLeftThumb; + } +} + +bool ValidateIOSTouchControlDefinitions( + const std::vector& controls) { + if (controls.empty() || controls.size() > kMaxIOSTouchControls) { + return false; + } + + std::unordered_set identifiers; + size_t move_stick_count = 0; + size_t look_zone_count = 0; + size_t pause_button_count = 0; + for (const auto& control : controls) { + if (control.identifier.empty() || + control.identifier.size() > kMaxTouchControlIdentifierLength || + !identifiers.insert(control.identifier).second) { + return false; + } + + switch (control.type) { + case IOSTouchControlType::kMoveStick: + ++move_stick_count; + break; + case IOSTouchControlType::kLookSwipeZone: + ++look_zone_count; + break; + case IOSTouchControlType::kPauseButton: + ++pause_button_count; + break; + case IOSTouchControlType::kActionButton: + break; + } + } + + return move_stick_count <= 1 && look_zone_count <= 1 && + pause_button_count <= 1; +} + +float ClampNormalizedScalar(float value) { + return std::clamp(value, 0.0f, 1.0f); +} + +float ClampPositiveScalar(float value, float min_value, float max_value) { + return std::clamp(value, min_value, max_value); +} + +float MaxNormalizedControlSize(IOSTouchControlType control_type) { + return control_type == IOSTouchControlType::kLookSwipeZone ? 1.0f + : kMaxControlSize; +} + +IOSTouchRect ClampNormalizedRect(const IOSTouchRect& rect, + IOSTouchControlType control_type) { + IOSTouchRect result = rect; + const float max_control_size = MaxNormalizedControlSize(control_type); + result.width = + ClampPositiveScalar(result.width, kMinControlSize, max_control_size); + result.height = + ClampPositiveScalar(result.height, kMinControlSize, max_control_size); + result.x = std::clamp(result.x, 0.0f, 1.0f - result.width); + result.y = std::clamp(result.y, 0.0f, 1.0f - result.height); + return result; +} + +void EncodeControlFrame(const IOSTouchRect& rect, toml::table* table) { + if (!table) { + return; + } + table->insert_or_assign("x", rect.x); + table->insert_or_assign("y", rect.y); + table->insert_or_assign("width", rect.width); + table->insert_or_assign("height", rect.height); +} + +void ApplyControlFrame(const toml::table& table, IOSTouchRect* rect, + IOSTouchControlType control_type) { + if (!rect) { + return; + } + + IOSTouchRect next_rect = *rect; + if (auto value = table["x"].value()) { + next_rect.x = static_cast(*value); + } + if (auto value = table["y"].value()) { + next_rect.y = static_cast(*value); + } + if (auto value = table["width"].value()) { + next_rect.width = static_cast(*value); + } + if (auto value = table["height"].value()) { + next_rect.height = static_cast(*value); + } + *rect = ClampNormalizedRect(next_rect, control_type); +} + +void EncodeInteractionBehavior(const IOSTouchInteractionBehavior& behavior, + toml::table* table) { + if (!table) { + return; + } + table->insert_or_assign( + "trigger", + std::string(IOSTouchInteractionTriggerConfigName(behavior.trigger))); + table->insert_or_assign( + "action", std::string(IOSTouchActionConfigName(behavior.action))); + table->insert_or_assign("enables_relative_look", + behavior.enables_relative_look); + table->insert_or_assign("relative_look_scale", behavior.relative_look_scale); + table->insert_or_assign("hold_seconds", behavior.hold_seconds); + table->insert_or_assign("drag_threshold_points", + behavior.drag_threshold_points); +} + +void ApplyInteractionBehavior(const toml::table& table, + IOSTouchInteractionBehavior* behavior) { + if (!behavior) { + return; + } + + bool hold_seconds_overridden = false; + if (auto value = table["trigger"].value()) { + IOSTouchInteractionTrigger trigger = behavior->trigger; + if (ParseIOSTouchInteractionTriggerConfigName(*value, &trigger)) { + behavior->trigger = trigger; + } + } + if (auto value = table["action"].value()) { + IOSTouchAction action = behavior->action; + if (ParseIOSTouchActionConfigName(*value, &action)) { + behavior->action = action; + } + } + if (auto value = table["enables_relative_look"].value()) { + behavior->enables_relative_look = *value; + } + if (auto value = table["relative_look_scale"].value()) { + behavior->relative_look_scale = + ClampPositiveScalar(static_cast(*value), 0.1f, 2.0f); + } + if (auto value = table["hold_seconds"].value()) { + behavior->hold_seconds = + ClampPositiveScalar(static_cast(*value), 0.05f, 1.0f); + hold_seconds_overridden = true; + } + if (auto value = table["drag_threshold_points"].value()) { + behavior->drag_threshold_points = + ClampPositiveScalar(static_cast(*value), 2.0f, 96.0f); + } + if (!hold_seconds_overridden || + UsesLegacyTouchBehaviorHoldDefault(behavior->hold_seconds)) { + // Older layouts stored trigger-specific defaults directly, so preserve the + // effective duration when migrating from those legacy values. + behavior->hold_seconds = + DefaultIOSTouchHoldSecondsForInteractionTrigger(behavior->trigger); + } + if (behavior->trigger == IOSTouchInteractionTrigger::kNone) { + behavior->enables_relative_look = false; + } else if (behavior->trigger == IOSTouchInteractionTrigger::kHoldDrag && + !table["enables_relative_look"].value().has_value()) { + behavior->enables_relative_look = true; + } +} + +void ApplyStoredControlLabelState(const toml::table& control_table, + IOSTouchControlDefinition* control) { + if (!control) { + return; + } + + std::optional stored_label; + if (auto value = control_table["label"].value()) { + stored_label = TrimIOSTouchLabel(*value); + } + std::optional label_uses_default = + control_table["label_uses_default"].value(); + std::optional label_hidden = + control_table["label_hidden"].value(); + + if (label_uses_default.has_value()) { + if (*label_uses_default) { + ResetIOSTouchControlLabel(control); + } else if (stored_label.has_value()) { + SetIOSTouchControlCustomLabel(*stored_label, control); + } else if (!IOSTouchControlHasCustomLabel(*control)) { + ResetIOSTouchControlLabel(control); + } + } else if (stored_label.has_value()) { + if (stored_label->empty()) { + ResetIOSTouchControlLabel(control); + if (!label_hidden.has_value()) { + control->label_hidden = true; + } + } else if (*stored_label == DefaultIOSTouchControlLabel(*control)) { + ResetIOSTouchControlLabel(control); + } else { + SetIOSTouchControlCustomLabel(*stored_label, control); + } + } + + if (label_hidden.has_value()) { + control->label_hidden = *label_hidden; + } +} + +IOSTouchControlDefinition DecodeIOSTouchControlDefinition( + const toml::table& control_table, size_t control_index) { + IOSTouchControlType control_type = IOSTouchControlType::kActionButton; + if (auto value = control_table["type"].value()) { + ParseIOSTouchControlTypeConfigName(*value, &control_type); + } + + IOSTouchControlDefinition control = + MakeDefaultIOSTouchControlDefinitionImpl(control_type); + if (auto value = control_table["id"].value()) { + control.identifier = *value; + } else { + control.identifier = + std::string(IOSTouchControlTypeConfigName(control_type)) + "_" + + std::to_string(control_index + 1); + } + if (auto value = control_table["shape"].value()) { + IOSTouchControlShape shape = control.shape; + if (ParseIOSTouchControlShapeConfigName(*value, &shape)) { + control.shape = shape; + } + } + if (auto value = control_table["action"].value()) { + IOSTouchAction action = control.action; + if (ParseIOSTouchActionConfigName(*value, &action) && + IsSupportedIOSTouchPrimaryAction(control.type, action)) { + ConfigureIOSTouchControlAction(action, &control); + } + } + ApplyStoredControlLabelState(control_table, &control); + ApplyControlFrame(control_table, &control.normalized_frame, control.type); + if (auto value = control_table["has_portrait_frame"].value()) { + control.has_portrait_frame = *value; + } + if (control.has_portrait_frame) { + if (const toml::table* portrait_frame_table = + control_table["portrait_frame"].as_table()) { + // Seed from the landscape frame so any missing axes inherit the + // baseline rather than resetting to zero — matches the runtime + // lazy-seed behaviour in MutableActiveControlFrameForOrientation. + control.portrait_normalized_frame = control.normalized_frame; + ApplyControlFrame(*portrait_frame_table, + &control.portrait_normalized_frame, control.type); + } else { + // `has_portrait_frame == true` without a `portrait_frame` block — the + // override is meaningless, so treat it as unset and fall back to the + // landscape frame at read time. + control.has_portrait_frame = false; + } + } + if (auto value = control_table["deadzone"].value()) { + control.deadzone = ClampNormalizedScalar(static_cast(*value)); + } + if (auto value = control_table["activation_radius"].value()) { + control.activation_radius = + ClampPositiveScalar(static_cast(*value), 0.0f, 1.0f); + } + if (auto value = control_table["visual_opacity"].value()) { + control.visual_opacity = ClampNormalizedScalar(static_cast(*value)); + } + if (auto value = control_table["tint_style"].value()) { + IOSTouchTintStyle tint_style = control.tint_style; + if (ParseIOSTouchTintStyleConfigName(*value, &tint_style)) { + control.tint_style = tint_style; + } + } + if (auto value = control_table["hold_while_captured"].value()) { + control.hold_while_captured = *value; + } + if (auto value = control_table["enables_relative_look"].value()) { + control.enables_relative_look = *value; + } + if (auto value = control_table["relative_look_scale"].value()) { + control.relative_look_scale = + ClampPositiveScalar(static_cast(*value), 0.25f, 4.0f); + } + if (auto value = control_table["move_with_dpad_ring"].value()) { + control.move_with_dpad_ring = *value; + } + if (const toml::table* behavior_table = + control_table["secondary_behavior"].as_table()) { + ApplyInteractionBehavior(*behavior_table, &control.secondary_behavior); + } + if (auto value = control_table["capture_priority"].value()) { + control.capture_priority = + static_cast(std::clamp(*value, 0, 255)); + } + ApplyDerivedTouchControlDefaults(&control); + return control; +} +} // namespace + +toml::table EncodeIOSTouchLayoutModel(const IOSTouchLayoutModel& layout) { + toml::table table; + table.insert_or_assign("schema_version", kCurrentTouchLayoutSchemaVersion); + table.insert_or_assign("layout_id", layout.layout_id); + table.insert_or_assign("display_name", layout.display_name); + table.insert_or_assign("author", layout.author); + table.insert_or_assign("base_template", layout.base_template); + + toml::array controls_array; + for (const auto& control : layout.controls) { + toml::table control_table; + control_table.insert_or_assign("id", control.identifier); + control_table.insert_or_assign( + "type", std::string(IOSTouchControlTypeConfigName(control.type))); + control_table.insert_or_assign( + "shape", std::string(IOSTouchControlShapeConfigName(control.shape))); + control_table.insert_or_assign("label", + IOSTouchConfiguredControlLabel(control)); + control_table.insert_or_assign("label_uses_default", + control.label_uses_default); + control_table.insert_or_assign("label_hidden", control.label_hidden); + EncodeControlFrame(control.normalized_frame, &control_table); + control_table.insert_or_assign("has_portrait_frame", + control.has_portrait_frame); + if (control.has_portrait_frame) { + toml::table portrait_frame_table; + EncodeControlFrame(control.portrait_normalized_frame, + &portrait_frame_table); + control_table.insert_or_assign("portrait_frame", + std::move(portrait_frame_table)); + } + control_table.insert_or_assign( + "action", std::string(IOSTouchActionConfigName(control.action))); + control_table.insert_or_assign("deadzone", control.deadzone); + control_table.insert_or_assign("activation_radius", + control.activation_radius); + control_table.insert_or_assign( + "tint_style", + std::string(IOSTouchTintStyleConfigName(control.tint_style))); + control_table.insert_or_assign("visual_opacity", control.visual_opacity); + control_table.insert_or_assign("hold_while_captured", + control.hold_while_captured); + control_table.insert_or_assign("enables_relative_look", + control.enables_relative_look); + control_table.insert_or_assign("relative_look_scale", + control.relative_look_scale); + control_table.insert_or_assign("move_with_dpad_ring", + control.move_with_dpad_ring); + toml::table secondary_behavior_table; + EncodeInteractionBehavior(control.secondary_behavior, + &secondary_behavior_table); + control_table.insert_or_assign("secondary_behavior", + std::move(secondary_behavior_table)); + control_table.insert_or_assign( + "capture_priority", static_cast(control.capture_priority)); + controls_array.push_back(std::move(control_table)); + } + + table.insert_or_assign("controls", std::move(controls_array)); + return table; +} + +bool ApplyIOSTouchLayoutModel(const toml::table& table, + IOSTouchLayoutModel* layout) { + if (!layout) { + return false; + } + + if (auto schema_version = table["schema_version"].value()) { + if (*schema_version <= 0 || + *schema_version > kCurrentTouchLayoutSchemaVersion) { + return false; + } + } + + if (auto value = table["layout_id"].value()) { + layout->layout_id = *value; + } + if (auto value = table["display_name"].value()) { + layout->display_name = *value; + } + if (auto value = table["author"].value()) { + layout->author = *value; + } + if (auto value = table["base_template"].value()) { + layout->base_template = *value; + } + + const toml::array* controls_array = table["controls"].as_array(); + if (controls_array) { + if (controls_array->size() > kMaxIOSTouchControls) { + return false; + } + std::vector controls; + controls.reserve(controls_array->size()); + size_t control_index = 0; + for (const auto& control_node : *controls_array) { + const toml::table* control_table = control_node.as_table(); + if (control_table) { + controls.push_back( + DecodeIOSTouchControlDefinition(*control_table, control_index)); + ++control_index; + } + } + if (!ValidateIOSTouchControlDefinitions(controls)) { + return false; + } + layout->controls = std::move(controls); + return true; + } + + const toml::table* controls_table = table["controls"].as_table(); + if (!controls_table) { + return true; + } + + // Mutate a copy of the seed controls, then swap on validation success. + // The previous implementation mutated layout->controls in place and only + // validated after the loop — a malformed override could leave the live + // layout half-applied and fail validation, leaving partial damage behind. + std::vector controls = layout->controls; + for (auto& control : controls) { + const toml::table* control_table = + (*controls_table)[control.identifier].as_table(); + if (!control_table) { + continue; + } + if (auto value = (*control_table)["action"].value()) { + IOSTouchAction parsed_action = control.action; + if (ParseIOSTouchActionConfigName(*value, &parsed_action) && + IsSupportedIOSTouchPrimaryAction(control.type, parsed_action)) { + ConfigureIOSTouchControlAction(parsed_action, &control); + } + } + ApplyStoredControlLabelState(*control_table, &control); + if (auto value = (*control_table)["shape"].value()) { + IOSTouchControlShape shape = control.shape; + if (ParseIOSTouchControlShapeConfigName(*value, &shape)) { + control.shape = shape; + } + } + if (auto value = (*control_table)["tint_style"].value()) { + IOSTouchTintStyle tint_style = control.tint_style; + if (ParseIOSTouchTintStyleConfigName(*value, &tint_style)) { + control.tint_style = tint_style; + } + } + ApplyControlFrame(*control_table, &control.normalized_frame, control.type); + if (auto value = (*control_table)["has_portrait_frame"].value()) { + control.has_portrait_frame = *value; + } + if (control.has_portrait_frame) { + if (const toml::table* portrait_frame_table = + (*control_table)["portrait_frame"].as_table()) { + // Seed from the landscape baseline before applying the override so + // partial portrait_frame blocks don't silently zero an axis. + control.portrait_normalized_frame = control.normalized_frame; + ApplyControlFrame(*portrait_frame_table, + &control.portrait_normalized_frame, control.type); + } else { + control.has_portrait_frame = false; + } + } + if (auto value = (*control_table)["deadzone"].value()) { + control.deadzone = ClampNormalizedScalar(static_cast(*value)); + } + if (auto value = (*control_table)["activation_radius"].value()) { + control.activation_radius = + ClampPositiveScalar(static_cast(*value), 0.0f, 1.0f); + } + if (auto value = (*control_table)["visual_opacity"].value()) { + control.visual_opacity = + ClampNormalizedScalar(static_cast(*value)); + } + if (auto value = (*control_table)["hold_while_captured"].value()) { + control.hold_while_captured = *value; + } + if (auto value = (*control_table)["enables_relative_look"].value()) { + control.enables_relative_look = *value; + } + if (auto value = (*control_table)["relative_look_scale"].value()) { + control.relative_look_scale = + ClampPositiveScalar(static_cast(*value), 0.25f, 4.0f); + } + if (auto value = (*control_table)["move_with_dpad_ring"].value()) { + control.move_with_dpad_ring = *value; + } + if (const toml::table* behavior_table = + (*control_table)["secondary_behavior"].as_table()) { + ApplyInteractionBehavior(*behavior_table, &control.secondary_behavior); + } + if (auto value = (*control_table)["capture_priority"].value()) { + control.capture_priority = + static_cast(std::clamp(*value, 0, 255)); + } + ApplyDerivedTouchControlDefaults(&control); + } + + if (!ValidateIOSTouchControlDefinitions(controls)) { + return false; + } + layout->controls = std::move(controls); + return true; +} + +bool IsValidIOSTouchLayoutModel(const IOSTouchLayoutModel& layout) { + return ValidateIOSTouchControlDefinitions(layout.controls); +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_layout_ios_defaults.cc b/src/xenia/hid/touch/touch_layout_ios_defaults.cc new file mode 100644 index 000000000..e6e9a85e0 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_defaults.cc @@ -0,0 +1,123 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios_internal.h" + +#include + +namespace xe { +namespace hid { +namespace touch { + +namespace { + +constexpr IOSTouchRect kMoveStickFrame = {0.01f, 0.55f, 0.28f, 0.35f}; +constexpr IOSTouchRect kLookZoneFrame = {0.20f, 0.06f, 0.78f, 0.86f}; +constexpr IOSTouchRect kPauseFrame = {0.015f, 0.02f, 0.09f, 0.085f}; +constexpr IOSTouchRect kAimFrame = {0.60f, 0.67f, 0.13f, 0.14f}; +constexpr IOSTouchRect kReloadFrame = {0.72f, 0.47f, 0.13f, 0.14f}; +constexpr IOSTouchRect kJumpFrame = {0.86f, 0.54f, 0.13f, 0.14f}; +constexpr IOSTouchRect kFireFrame = {0.75f, 0.74f, 0.23f, 0.17f}; + +IOSTouchControlDefinition MakeActionButton(const char* identifier, + IOSTouchAction action, + const IOSTouchRect& normalized_frame, + uint8_t capture_priority) { + IOSTouchControlDefinition control; + control.identifier = identifier; + control.type = IOSTouchControlType::kActionButton; + control.shape = IOSTouchControlShape::kCircle; + control.normalized_frame = normalized_frame; + control.activation_radius = 0.5f; + control.visual_opacity = 0.92f; + control.capture_priority = capture_priority; + ConfigureIOSTouchControlAction(action, &control); + return control; +} + +} // namespace + +IOSTouchControlDefinition MakeDefaultIOSTouchControlDefinitionImpl( + IOSTouchControlType type) { + switch (type) { + case IOSTouchControlType::kMoveStick: { + IOSTouchControlDefinition control; + control.identifier = "move_stick"; + control.type = IOSTouchControlType::kMoveStick; + control.action = IOSTouchAction::kMove; + control.shape = IOSTouchControlShape::kCircle; + control.normalized_frame = kMoveStickFrame; + control.deadzone = 0.14f; + control.activation_radius = 0.48f; + control.visual_opacity = 0.80f; + control.capture_priority = 200; + return control; + } + case IOSTouchControlType::kLookSwipeZone: { + IOSTouchControlDefinition control; + control.identifier = "look_zone"; + control.type = IOSTouchControlType::kLookSwipeZone; + control.action = IOSTouchAction::kLook; + control.shape = IOSTouchControlShape::kRoundedRect; + control.normalized_frame = kLookZoneFrame; + control.visual_opacity = 0.0f; + control.capture_priority = 16; + return control; + } + case IOSTouchControlType::kPauseButton: { + IOSTouchControlDefinition control; + control.identifier = "pause_button"; + control.type = IOSTouchControlType::kPauseButton; + control.action = IOSTouchAction::kPauseMenu; + control.shape = IOSTouchControlShape::kRoundedRect; + control.normalized_frame = kPauseFrame; + control.visual_opacity = 0.92f; + control.capture_priority = 255; + return control; + } + case IOSTouchControlType::kActionButton: + default: + return MakeActionButton("action_button", IOSTouchAction::kButtonA, + IOSTouchRect{0.82f, 0.32f, 0.12f, 0.14f}, 232); + } +} + +IOSTouchLayoutModel CreateDefaultIOSFPSLayoutModel() { + IOSTouchLayoutModel layout; + layout.layout_id = "fps_phase1_halo_like"; + layout.display_name = "FPS Phase 1"; + layout.author = "XeniOS"; + layout.base_template = "fps_standard"; + + layout.controls.push_back(MakeDefaultIOSTouchControlDefinitionImpl( + IOSTouchControlType::kMoveStick)); + layout.controls.push_back(MakeDefaultIOSTouchControlDefinitionImpl( + IOSTouchControlType::kLookSwipeZone)); + layout.controls.push_back(MakeDefaultIOSTouchControlDefinitionImpl( + IOSTouchControlType::kPauseButton)); + layout.controls.push_back(MakeActionButton( + "aim_button", IOSTouchAction::kLeftTrigger, kAimFrame, 240)); + layout.controls.push_back(MakeActionButton( + "reload_button", IOSTouchAction::kButtonX, kReloadFrame, 236)); + layout.controls.push_back(MakeActionButton( + "jump_button", IOSTouchAction::kButtonA, kJumpFrame, 238)); + layout.controls.push_back(MakeActionButton( + "fire_button", IOSTouchAction::kRightTrigger, kFireFrame, 242)); + + return layout; +} + +IOSTouchControlDefinition CreateDefaultIOSTouchControlDefinition( + IOSTouchControlType type) { + return MakeDefaultIOSTouchControlDefinitionImpl(type); +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_layout_ios_geometry.cc b/src/xenia/hid/touch/touch_layout_ios_geometry.cc new file mode 100644 index 000000000..8370a6532 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_geometry.cc @@ -0,0 +1,64 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace hid { +namespace touch { + +IOSTouchRect ResolveIOSTouchRect(const IOSTouchRect& normalized_rect, + const IOSTouchLayoutSpace& safe_area) { + if (safe_area.IsEmpty()) { + return IOSTouchRect{}; + } + return IOSTouchRect{ + safe_area.origin_x + normalized_rect.x * safe_area.width, + safe_area.origin_y + normalized_rect.y * safe_area.height, + normalized_rect.width * safe_area.width, + normalized_rect.height * safe_area.height, + }; +} + +bool IOSTouchRectContainsPoint(const IOSTouchRect& rect, + const IOSTouchPoint& point) { + const float rect_max_x = rect.x + rect.width; + const float rect_max_y = rect.y + rect.height; + return point.x >= rect.x && point.x <= rect_max_x && point.y >= rect.y && + point.y <= rect_max_y; +} + +const IOSTouchRect& ActiveControlFrameForOrientation( + const IOSTouchControlDefinition& control, bool is_portrait) { + if (is_portrait && control.has_portrait_frame) { + return control.portrait_normalized_frame; + } + return control.normalized_frame; +} + +IOSTouchRect& MutableActiveControlFrameForOrientation( + IOSTouchControlDefinition& control, bool is_portrait) { + if (!is_portrait) { + return control.normalized_frame; + } + if (!control.has_portrait_frame) { + // Lazily promote: seed the portrait frame from the current landscape + // baseline so the very first portrait edit nudges from a sensible + // starting point instead of (0, 0). Callers that explicitly want the + // portrait override empty (e.g. "Copy Layout from Landscape") set the + // fields directly rather than going through this helper. + control.portrait_normalized_frame = control.normalized_frame; + control.has_portrait_frame = true; + } + return control.portrait_normalized_frame; +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_layout_ios_internal.h b/src/xenia/hid/touch/touch_layout_ios_internal.h new file mode 100644 index 000000000..50a14ea29 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_internal.h @@ -0,0 +1,31 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_HID_TOUCH_LAYOUT_IOS_INTERNAL_H_ +#define XENIA_HID_TOUCH_LAYOUT_IOS_INTERNAL_H_ + +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace hid { +namespace touch { + +std::string TrimIOSTouchLabel(std::string value); +std::string DefaultIOSTouchControlLabel( + const IOSTouchControlDefinition& control); +IOSTouchControlDefinition MakeDefaultIOSTouchControlDefinitionImpl( + IOSTouchControlType type); + +} // namespace touch +} // namespace hid +} // namespace xe + +#endif // XENIA_HID_TOUCH_LAYOUT_IOS_INTERNAL_H_ diff --git a/src/xenia/hid/touch/touch_layout_ios_labels.cc b/src/xenia/hid/touch/touch_layout_ios_labels.cc new file mode 100644 index 000000000..d6524ec01 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_labels.cc @@ -0,0 +1,329 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios_internal.h" + +#include +#include +#include + +namespace xe { +namespace hid { +namespace touch { + +namespace { + +constexpr float kDefaultTouchBehaviorHoldSeconds = 0.30f; +constexpr float kDefaultTouchBehaviorHoldDragSeconds = 0.16f; +constexpr float kDefaultTouchBehaviorDoubleTapSeconds = 0.24f; + +float DefaultHoldSecondsForInteractionTriggerImpl( + IOSTouchInteractionTrigger trigger) { + switch (trigger) { + case IOSTouchInteractionTrigger::kHold: + return kDefaultTouchBehaviorHoldSeconds; + case IOSTouchInteractionTrigger::kHoldDrag: + return kDefaultTouchBehaviorHoldDragSeconds; + case IOSTouchInteractionTrigger::kDoubleTap: + case IOSTouchInteractionTrigger::kDoubleTapForward: + return kDefaultTouchBehaviorDoubleTapSeconds; + case IOSTouchInteractionTrigger::kNone: + default: + return kDefaultTouchBehaviorHoldSeconds; + } +} + +} // namespace + +std::string TrimIOSTouchLabel(std::string value) { + auto not_space = [](char c) { + return !std::isspace(static_cast(c)); + }; + auto begin = std::find_if(value.begin(), value.end(), not_space); + if (begin == value.end()) { + return {}; + } + auto end = std::find_if(value.rbegin(), value.rend(), not_space).base(); + return std::string(begin, end); +} + +std::string DefaultIOSTouchControlLabel( + const IOSTouchControlDefinition& control) { + return std::string(IOSTouchActionDisplayName(control.action)); +} + +bool IsSupportedIOSTouchPrimaryAction(IOSTouchControlType control_type, + IOSTouchAction action) { + switch (control_type) { + case IOSTouchControlType::kMoveStick: + return action == IOSTouchAction::kMove || action == IOSTouchAction::kLook; + case IOSTouchControlType::kLookSwipeZone: + return action == IOSTouchAction::kLook || action == IOSTouchAction::kMove; + case IOSTouchControlType::kPauseButton: + return action == IOSTouchAction::kPauseMenu; + case IOSTouchControlType::kActionButton: + return IsEditableIOSTouchAction(action); + } + return false; +} + +bool IsEditableIOSTouchAction(IOSTouchAction action) { + for (IOSTouchAction editable_action : kIOSTouchEditableActions) { + if (editable_action == action) { + return true; + } + } + return false; +} + +const char* IOSTouchActionDisplayName(IOSTouchAction action) { + switch (action) { + case IOSTouchAction::kNone: + return "Unused"; + case IOSTouchAction::kMove: + return "Move"; + case IOSTouchAction::kLook: + return "Look"; + case IOSTouchAction::kPauseMenu: + return "Pause"; + case IOSTouchAction::kButtonA: + return "A"; + case IOSTouchAction::kButtonB: + return "B"; + case IOSTouchAction::kButtonX: + return "X"; + case IOSTouchAction::kButtonY: + return "Y"; + case IOSTouchAction::kLeftBumper: + return "LB"; + case IOSTouchAction::kRightBumper: + return "RB"; + case IOSTouchAction::kLeftTrigger: + return "LT"; + case IOSTouchAction::kRightTrigger: + return "RT"; + case IOSTouchAction::kBack: + return "Back"; + case IOSTouchAction::kStart: + return "Start"; + case IOSTouchAction::kLeftThumb: + return "LS"; + case IOSTouchAction::kRightThumb: + return "RS"; + case IOSTouchAction::kDpadUp: + return "D-Pad Up"; + case IOSTouchAction::kDpadDown: + return "D-Pad Down"; + case IOSTouchAction::kDpadLeft: + return "D-Pad Left"; + case IOSTouchAction::kDpadRight: + return "D-Pad Right"; + } + return "Control"; +} + +const char* IOSTouchInteractionTriggerDisplayName( + IOSTouchInteractionTrigger trigger) { + switch (trigger) { + case IOSTouchInteractionTrigger::kNone: + return "Off"; + case IOSTouchInteractionTrigger::kHold: + return "Hold"; + case IOSTouchInteractionTrigger::kHoldDrag: + return "Hold + Drag"; + case IOSTouchInteractionTrigger::kDoubleTap: + return "Double Tap"; + case IOSTouchInteractionTrigger::kDoubleTapForward: + return "Double Tap Forward"; + } + return "Off"; +} + +float DefaultIOSTouchHoldSecondsForInteractionTrigger( + IOSTouchInteractionTrigger trigger) { + return DefaultHoldSecondsForInteractionTriggerImpl(trigger); +} + +bool IOSTouchControlHasCustomLabel(const IOSTouchControlDefinition& control) { + return !control.label_uses_default && !control.label.empty(); +} + +std::string IOSTouchConfiguredControlLabel( + const IOSTouchControlDefinition& control) { + if (!control.label_uses_default && !control.label.empty()) { + return control.label; + } + return DefaultIOSTouchControlLabel(control); +} + +std::string IOSTouchVisibleControlLabel( + const IOSTouchControlDefinition& control) { + if (control.label_hidden) { + return {}; + } + return IOSTouchConfiguredControlLabel(control); +} + +void SetIOSTouchControlCustomLabel(std::string label, + IOSTouchControlDefinition* control) { + if (!control) { + return; + } + + label = TrimIOSTouchLabel(std::move(label)); + if (label.empty()) { + ResetIOSTouchControlLabel(control); + return; + } + + control->label = std::move(label); + control->label_uses_default = false; +} + +void ResetIOSTouchControlLabel(IOSTouchControlDefinition* control) { + if (!control) { + return; + } + + control->label.clear(); + control->label_uses_default = true; +} + +bool ConfigureIOSTouchControlAction(IOSTouchAction action, + IOSTouchControlDefinition* control) { + if (!control) { + return false; + } + + IOSTouchControlDefinition updated = *control; + const bool keep_custom_label = IOSTouchControlHasCustomLabel(updated); + updated.action = action; + if (!keep_custom_label) { + ResetIOSTouchControlLabel(&updated); + } + updated.mapped_buttons = 0; + updated.mapped_left_trigger = 0; + updated.mapped_right_trigger = 0; + updated.hold_while_captured = false; + updated.enables_relative_look = false; + updated.relative_look_scale = 1.0f; + + switch (action) { + case IOSTouchAction::kMove: + case IOSTouchAction::kLook: + case IOSTouchAction::kPauseMenu: + case IOSTouchAction::kNone: + break; + case IOSTouchAction::kButtonA: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_A; + break; + case IOSTouchAction::kButtonB: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_B; + break; + case IOSTouchAction::kButtonX: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_X; + break; + case IOSTouchAction::kButtonY: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_Y; + break; + case IOSTouchAction::kLeftBumper: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_LEFT_SHOULDER; + break; + case IOSTouchAction::kRightBumper: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_RIGHT_SHOULDER; + break; + case IOSTouchAction::kLeftTrigger: + updated.mapped_left_trigger = 255; + updated.hold_while_captured = true; + updated.enables_relative_look = true; + updated.relative_look_scale = 0.80f; + break; + case IOSTouchAction::kRightTrigger: + updated.mapped_right_trigger = 255; + updated.hold_while_captured = true; + updated.enables_relative_look = true; + updated.relative_look_scale = 0.92f; + break; + case IOSTouchAction::kBack: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_BACK; + break; + case IOSTouchAction::kStart: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_START; + break; + case IOSTouchAction::kLeftThumb: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_LEFT_THUMB; + break; + case IOSTouchAction::kRightThumb: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_RIGHT_THUMB; + break; + case IOSTouchAction::kDpadUp: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_DPAD_UP; + break; + case IOSTouchAction::kDpadDown: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_DPAD_DOWN; + break; + case IOSTouchAction::kDpadLeft: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_DPAD_LEFT; + break; + case IOSTouchAction::kDpadRight: + updated.mapped_buttons = hid::X_INPUT_GAMEPAD_DPAD_RIGHT; + break; + default: + return false; + } + + *control = std::move(updated); + return true; +} + +IOSTouchAction NextEditableIOSTouchAction(IOSTouchAction action) { + for (size_t index = 0; index < kIOSTouchEditableActions.size(); ++index) { + if (kIOSTouchEditableActions[index] == action) { + return kIOSTouchEditableActions[(index + 1) % + kIOSTouchEditableActions.size()]; + } + } + return kIOSTouchEditableActions[0]; +} + +const char* IOSTouchTintStyleDisplayName(IOSTouchTintStyle tint_style) { + switch (tint_style) { + case IOSTouchTintStyle::kAuto: + return "Auto"; + case IOSTouchTintStyle::kAmber: + return "Amber"; + case IOSTouchTintStyle::kSky: + return "Sky"; + case IOSTouchTintStyle::kMint: + return "Mint"; + case IOSTouchTintStyle::kRose: + return "Rose"; + case IOSTouchTintStyle::kLime: + return "Lime"; + case IOSTouchTintStyle::kCoral: + return "Coral"; + case IOSTouchTintStyle::kSlate: + return "Slate"; + } + return "Auto"; +} + +IOSTouchTintStyle NextIOSTouchTintStyle(IOSTouchTintStyle tint_style) { + for (size_t index = 0; index < kIOSTouchEditableTintStyles.size(); ++index) { + if (kIOSTouchEditableTintStyles[index] == tint_style) { + return kIOSTouchEditableTintStyles[(index + 1) % + kIOSTouchEditableTintStyles.size()]; + } + } + return kIOSTouchEditableTintStyles[0]; +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/hid/touch/touch_layout_ios_runtime.cc b/src/xenia/hid/touch/touch_layout_ios_runtime.cc new file mode 100644 index 000000000..b8f3adda8 --- /dev/null +++ b/src/xenia/hid/touch/touch_layout_ios_runtime.cc @@ -0,0 +1,50 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/hid/touch/touch_layout_ios.h" + +#include + +namespace xe { +namespace hid { +namespace touch { + +void IOSTouchResolvedStateBuffer::Store(const IOSTouchResolvedState& state) { + std::lock_guard lock(mutex_); + state_ = state; +} + +IOSTouchResolvedState IOSTouchResolvedStateBuffer::Load() const { + std::lock_guard lock(mutex_); + return state_; +} + +IOSTouchRuntimeModel::IOSTouchRuntimeModel() + : layout_(CreateDefaultIOSFPSLayoutModel()) {} + +void IOSTouchRuntimeModel::SetLayout(IOSTouchLayoutModel layout) { + layout_ = std::move(layout); +} + +void IOSTouchRuntimeModel::StoreResolvedState( + const IOSTouchResolvedState& state) { + resolved_state_.Store(state); +} + +IOSTouchResolvedState IOSTouchRuntimeModel::LoadResolvedState() const { + return resolved_state_.Load(); +} + +void IOSTouchRuntimeModel::ResetResolvedState() { + resolved_state_.Store(IOSTouchResolvedState{}); +} + +} // namespace touch +} // namespace hid +} // namespace xe diff --git a/src/xenia/kernel/kernel_state.cc b/src/xenia/kernel/kernel_state.cc index 97b9ae79d..f607d199b 100644 --- a/src/xenia/kernel/kernel_state.cc +++ b/src/xenia/kernel/kernel_state.cc @@ -7,12 +7,18 @@ ****************************************************************************** */ +#include #include #include "xenia/kernel/kernel_state.h" #include "xenia/base/byte_stream.h" #include "xenia/base/logging.h" +#include "xenia/base/platform.h" +#include "xenia/base/threading.h" +#if XE_PLATFORM_IOS +#include "xenia/cpu/processor.h" +#endif // XE_PLATFORM_IOS #include "xenia/emulator.h" #include "xenia/hid/input_system.h" #include "xenia/kernel/guest_scheduler.h" @@ -26,6 +32,9 @@ #include "xenia/kernel/xmodule.h" #include "xenia/kernel/xnotifylistener.h" #include "xenia/kernel/xobject.h" +#if XE_PLATFORM_IOS +#include "xenia/kernel/xsocket.h" +#endif // XE_PLATFORM_IOS #include "xenia/kernel/xthread.h" #include "xenia/ui/imgui_host_notification.h" @@ -81,9 +90,19 @@ KernelState::KernelState(Emulator* emulator) } KernelState::~KernelState() { +#if XE_PLATFORM_IOS + const bool ios_title_stop = IsTitleStopRequestedIOS(); +#endif // XE_PLATFORM_IOS SetExecutableModule(nullptr); ShutdownDispatchThread(); +#if XE_PLATFORM_IOS + if (ios_title_stop && !WaitForTitleThreadsToExitIOS(500)) { + XELOGW( + "iOS: kernel reset continuing with guest thread(s) still marked " + "running"); + } +#endif // XE_PLATFORM_IOS executable_module_.reset(); user_modules_.clear(); @@ -98,11 +117,50 @@ KernelState::~KernelState() { shared_kernel_state_ = nullptr; } +#if XE_PLATFORM_IOS +bool KernelState::IsTitleStopRequestedIOS() const { + return processor_ && processor_->title_stop_requested_ios(); +} +#endif // XE_PLATFORM_IOS + void KernelState::ShutdownDispatchThread() { - if (dispatch_thread_running_) { - dispatch_thread_running_ = false; + if (dispatch_thread_running_ && dispatch_thread_) { +#if XE_PLATFORM_IOS + const bool ios_title_stop = IsTitleStopRequestedIOS(); +#endif // XE_PLATFORM_IOS + { + auto global_lock = global_critical_region_.Acquire(); + dispatch_thread_running_ = false; +#if XE_PLATFORM_IOS + if (ios_title_stop) { + dispatch_queue_.clear(); + } +#endif // XE_PLATFORM_IOS + } dispatch_cond_.notify_all(); +#if XE_PLATFORM_IOS + if (ios_title_stop) { + uint64_t timeout = static_cast(-500LL * 10000LL); + X_STATUS wait_status = dispatch_thread_->Wait(0, 0, 0, &timeout); + if (wait_status == X_STATUS_TIMEOUT) { + XELOGW( + "iOS: kernel dispatch thread did not exit during title stop; " + "terminating"); + dispatch_thread_->Terminate(0); + timeout = static_cast(-500LL * 10000LL); + wait_status = dispatch_thread_->Wait(0, 0, 0, &timeout); + if (wait_status == X_STATUS_TIMEOUT) { + XELOGW( + "iOS: kernel dispatch thread still did not exit after " + "termination request; continuing title teardown"); + } + } + } else { + dispatch_thread_->Wait(0, 0, 0, nullptr); + } +#else dispatch_thread_->Wait(0, 0, 0, nullptr); +#endif // XE_PLATFORM_IOS } } @@ -537,12 +595,28 @@ void KernelState::SetExecutableModule(object_ref module) { auto global_lock = global_critical_region_.AcquireDeferred(); while (dispatch_thread_running_) { global_lock.lock(); +#if XE_PLATFORM_IOS + if (!dispatch_thread_running_ || IsTitleStopRequestedIOS()) { + global_lock.unlock(); + break; + } +#endif // XE_PLATFORM_IOS if (dispatch_queue_.empty()) { dispatch_cond_.wait(global_lock); if (!dispatch_thread_running_) { global_lock.unlock(); break; } +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + global_lock.unlock(); + break; + } +#endif // XE_PLATFORM_IOS + if (dispatch_queue_.empty()) { + global_lock.unlock(); + continue; + } } auto fn = std::move(dispatch_queue_.front()); dispatch_queue_.pop_front(); @@ -565,6 +639,12 @@ void KernelState::LoadKernelModule(object_ref kernel_module) { object_ref KernelState::LoadUserModule( const std::string_view raw_name, bool call_entry) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return nullptr; + } +#endif // XE_PLATFORM_IOS + // Some games try to load relative to launch module, others specify full path. auto name = xe::utf8::find_name_from_guest_path(raw_name); std::string path(raw_name); @@ -610,6 +690,12 @@ object_ref KernelState::LoadUserModule( object_ref KernelState::LoadUserModuleFromMemory( const std::string_view raw_name, const void* addr, const size_t length) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return nullptr; + } +#endif // XE_PLATFORM_IOS + auto name = xe::utf8::find_base_name_from_guest_path(raw_name); object_ref module; @@ -643,11 +729,22 @@ object_ref KernelState::LoadUserModuleFromMemory( X_RESULT KernelState::FinishLoadingUserModule( const object_ref module, bool call_entry) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + // TODO(Gliniak): Apply custom patches here X_RESULT result = module->LoadContinue(); if (XFAILED(result)) { return result; } +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS module->Dump(); emulator_->patcher()->ApplyPatchesForTitle(memory_, module->title_id(), module->hash()); @@ -676,6 +773,12 @@ X_RESULT KernelState::FinishLoadingUserModule( X_RESULT KernelState::ApplyTitleUpdate( const object_ref title_module) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + const auto title_updates = FindTitleUpdate(title_module->title_id()); if (title_updates.empty()) { return X_STATUS_SUCCESS; @@ -860,10 +963,98 @@ void KernelState::InitXmpVolumePatch() { void KernelState::TerminateTitle() { XELOGI("KernelState::TerminateTitle"); +#if XE_PLATFORM_IOS + if (processor_) { + processor_->RequestTitleStopIOS(); + } + auto* title_process = + memory()->TranslateVirtual(GetTitleProcess()); + if (title_process) { + title_process->is_terminating = 1; + } + auto sockets = object_table()->GetObjectsByType(); + size_t closed_socket_count = 0; + for (auto& socket : sockets) { + if (socket && XSUCCEEDED(socket->Close())) { + ++closed_socket_count; + } + } + if (closed_socket_count) { + XELOGI("iOS: closed {} socket(s) for title stop", closed_socket_count); + } + + auto threads = object_table()->GetObjectsByType(); + for (auto& thread : threads) { + if (!thread || !thread->is_guest_thread() || + XThread::IsInThread(thread.get())) { + continue; + } + if (thread->guest_object()) { + for (uint32_t i = 0; i < 256 && thread->suspend_count() > 0; ++i) { + thread->Resume(nullptr); + } + } + } + if (XThread::IsInThread()) { + auto* current_thread = XThread::GetCurrentThread(); + if (current_thread && current_thread->is_guest_thread()) { + current_thread->Exit(0); + } + } + return; +#else xe::FlushLog(); std::quick_exit(EXIT_SUCCESS); +#endif // XE_PLATFORM_IOS } +#if XE_PLATFORM_IOS +bool KernelState::WaitForTitleThreadsToExitIOS(uint32_t timeout_ms) { + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + + while (true) { + size_t running_guest_threads = 0; + auto threads = object_table()->GetObjectsByType(); + for (auto& thread : threads) { + if (!thread || !thread->is_guest_thread()) { + continue; + } + + bool guest_thread_signaled = false; + if (thread->guest_object()) { + auto* guest_thread = thread->guest_object(); + guest_thread_signaled = + guest_thread->terminated || guest_thread->header.signal_state; + } + + bool thread_active = thread->is_running() || !guest_thread_signaled; + if (auto* native_thread = thread->thread()) { + thread_active |= + xe::threading::Wait(native_thread, false, + std::chrono::milliseconds::zero()) == + xe::threading::WaitResult::kTimeout; + } + if (thread_active) { + ++running_guest_threads; + } + } + + if (!running_guest_threads) { + return true; + } + + if (std::chrono::steady_clock::now() >= deadline) { + XELOGW("iOS: timed out waiting for {} guest thread(s) to exit", + running_guest_threads); + return false; + } + + xe::threading::Sleep(std::chrono::milliseconds(10)); + } +} +#endif // XE_PLATFORM_IOS + void KernelState::RegisterThread(XThread* thread) { auto global_lock = global_critical_region_.Acquire(); threads_by_id_[thread->thread_id()] = thread; @@ -883,6 +1074,12 @@ void KernelState::OnThreadExecute(XThread* thread) { // Must be called on executing thread. assert_true(XThread::GetCurrentThread() == thread); +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return; + } +#endif // XE_PLATFORM_IOS + // Call DllMain(DLL_THREAD_ATTACH) for each user module: // https://msdn.microsoft.com/en-us/library/windows/desktop/ms682583%28v=vs.85%29.aspx auto thread_state = thread->thread_state(); @@ -912,6 +1109,13 @@ void KernelState::OnThreadExit(XThread* thread) { // Must be called on executing thread. assert_true(XThread::GetCurrentThread() == thread); +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + emulator()->processor()->OnThreadExit(thread->thread_id()); + return; + } +#endif // XE_PLATFORM_IOS + // Call DllMain(DLL_THREAD_DETACH) for each user module: // https://msdn.microsoft.com/en-us/library/windows/desktop/ms682583%28v=vs.85%29.aspx auto thread_state = thread->thread_state(); @@ -1082,6 +1286,11 @@ void KernelState::CompleteOverlappedDeferredEx( std::function completion_callback, uint32_t overlapped_ptr, std::function pre_callback, std::function post_callback) { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return; + } +#endif // XE_PLATFORM_IOS auto ptr = memory()->TranslateVirtual(overlapped_ptr); XOverlappedSetResult(ptr, X_ERROR_IO_PENDING); XOverlappedSetContext(ptr, XThread::GetCurrentThreadHandle()); @@ -1097,11 +1306,21 @@ void KernelState::CompleteOverlappedDeferredEx( auto global_lock = global_critical_region_.Acquire(); dispatch_queue_.push_back([this, completion_callback, overlapped_ptr, pre_callback, post_callback]() { +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return; + } +#endif // XE_PLATFORM_IOS if (pre_callback) { pre_callback(); } // 5454082B infinitely loads free roam in netplay without sleep. xe::threading::Sleep(kDeferredOverlappedDelayMillis); +#if XE_PLATFORM_IOS + if (IsTitleStopRequestedIOS()) { + return; + } +#endif // XE_PLATFORM_IOS uint32_t extended_error, length; auto result = completion_callback(extended_error, length); CompleteOverlappedEx(overlapped_ptr, result, extended_error, length); diff --git a/src/xenia/kernel/kernel_state.h b/src/xenia/kernel/kernel_state.h index a84bf4010..5acb81ce5 100644 --- a/src/xenia/kernel/kernel_state.h +++ b/src/xenia/kernel/kernel_state.h @@ -17,6 +17,7 @@ #include #include "xenia/base/bit_map.h" +#include "xenia/base/platform.h" #include "xenia/cpu/backend/backend.h" #include "xenia/cpu/export_resolver.h" #include "xenia/kernel/kernel.h" @@ -264,6 +265,10 @@ class KernelState { // Terminates a title: Unloads all modules, and kills all guest threads. // This DOES NOT RETURN if called from a guest thread! void TerminateTitle(); +#if XE_PLATFORM_IOS + bool IsTitleStopRequestedIOS() const; + bool WaitForTitleThreadsToExitIOS(uint32_t timeout_ms); +#endif // XE_PLATFORM_IOS // Gracefully stops the dispatch thread. Call before force-terminating // threads to avoid corrupting the CV it's blocked on. diff --git a/src/xenia/kernel/xam/achievement_manager.cc b/src/xenia/kernel/xam/achievement_manager.cc index 2834a8813..20d264699 100644 --- a/src/xenia/kernel/xam/achievement_manager.cc +++ b/src/xenia/kernel/xam/achievement_manager.cc @@ -8,6 +8,7 @@ */ #include "xenia/kernel/xam/achievement_manager.h" +#include "xenia/base/platform.h" #include "xenia/emulator.h" #include "xenia/gpu/graphics_system.h" #include "xenia/kernel/kernel_state.h" @@ -16,6 +17,10 @@ #include "xenia/kernel/xam/xdbf/gpd_info.h" #include "xenia/ui/audio_helper.h" #include "xenia/ui/imgui_guest_notification.h" +#if XE_PLATFORM_IOS +#include "xenia/ui/achievement_notification_payload.h" +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#endif // XE_PLATFORM_IOS DEFINE_bool(show_achievement_notification, true, "Show achievement notification on screen.", "UI"); @@ -88,7 +93,7 @@ void AchievementManager::EarnAchievement(const uint64_t xuid, // Something went really wrong! return; } - ShowAchievementEarnedNotification(&achievement.value()); + ShowAchievementEarnedNotification(xuid, title_id, &achievement.value()); } void AchievementManager::LoadTitleAchievements(const uint64_t xuid) const { @@ -146,14 +151,19 @@ bool AchievementManager::DoesAchievementExist( } void AchievementManager::ShowAchievementEarnedNotification( - const Achievement* achievement) const { + uint64_t xuid, uint32_t title_id, const Achievement* achievement) const { + const std::string title = "Achievement unlocked"; const std::string description = fmt::format("{}G - {}", achievement->gamerscore, xe::to_utf8(achievement->achievement_name)); const Emulator* emulator = kernel_state()->emulator(); - ui::WindowedAppContext& app_context = - emulator->display_window()->app_context(); + ui::Window* display_window = emulator ? emulator->display_window() : nullptr; + if (!display_window) { + XELOGI("Achievement notification skipped: no display window available"); + return; + } + ui::WindowedAppContext* app_context = &display_window->app_context(); ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); // Use game-specified position if enabled, otherwise default to center-bottom @@ -161,13 +171,49 @@ void AchievementManager::ShowAchievementEarnedNotification( ? kernel_state()->notification_position_ : 2; - app_context.CallInUIThread([imgui_drawer, description, position]() { +#if XE_PLATFORM_IOS + static constexpr size_t kMaxIOSAchievementIconBytes = 512 * 1024; + ui::AchievementNotificationPayload payload; + payload.user_index = 0; + payload.position_id = position; + payload.xuid = xuid; + payload.title_id = title_id; + payload.achievement_id = achievement->achievement_id; + payload.gamerscore = achievement->gamerscore; + payload.title = "Achievement unlocked"; + payload.description = description; + const auto icon = default_achievements_backend_->GetAchievementIcon( + xuid, title_id, achievement->achievement_id); + if (icon.size() <= kMaxIOSAchievementIconBytes) { + payload.icon_data.assign(icon.begin(), icon.end()); + } else { + XELOGW("Achievement notification icon skipped: {} bytes exceeds cap", + icon.size()); + } + + app_context->CallInUIThread([app_context, imgui_drawer, title, description, + position, payload = std::move(payload)]() { +#else + app_context->CallInUIThread([imgui_drawer, title, description, position]() { +#endif // XE_PLATFORM_IOS // Play achievement sound ui::AudioHelper::Instance().PlayAchievementSound(); +#if XE_PLATFORM_IOS + auto* ios_context = dynamic_cast(app_context); + if (ios_context && ios_context->PresentAchievementNotification(payload)) { + return; + } +#endif // XE_PLATFORM_IOS + + if (!imgui_drawer) { + XELOGI("Achievement notification skipped: no ImGui drawer available"); + return; + } + // Show notification - new ui::AchievementNotificationWindow(imgui_drawer, "Achievement unlocked", - description, 0, position); + new ui::AchievementNotificationWindow(imgui_drawer, title, description, 0, + position); }); } diff --git a/src/xenia/kernel/xam/achievement_manager.h b/src/xenia/kernel/xam/achievement_manager.h index 7e9378898..09346e2a4 100644 --- a/src/xenia/kernel/xam/achievement_manager.h +++ b/src/xenia/kernel/xam/achievement_manager.h @@ -215,7 +215,8 @@ class AchievementManager { private: bool DoesAchievementExist(const uint32_t achievement_id) const; - void ShowAchievementEarnedNotification(const Achievement* achievement) const; + void ShowAchievementEarnedNotification(uint64_t xuid, uint32_t title_id, + const Achievement* achievement) const; // This contains all backends with exception of default storage. std::vector> diff --git a/src/xenia/kernel/xam/user_data.h b/src/xenia/kernel/xam/user_data.h index 6526b8824..504762cb5 100644 --- a/src/xenia/kernel/xam/user_data.h +++ b/src/xenia/kernel/xam/user_data.h @@ -181,6 +181,11 @@ class UserData { case X_USER_DATA_TYPE::DOUBLE: case X_USER_DATA_TYPE::INT64: return sizeof(uint64_t); + + case X_USER_DATA_TYPE::BINARY: + case X_USER_DATA_TYPE::UNSET: + case X_USER_DATA_TYPE::WSTRING: + break; } return sizeof(uint64_t); diff --git a/src/xenia/kernel/xam/xam_content.cc b/src/xenia/kernel/xam/xam_content.cc index 0007f1633..1dc56dab4 100644 --- a/src/xenia/kernel/xam/xam_content.cc +++ b/src/xenia/kernel/xam/xam_content.cc @@ -902,6 +902,13 @@ DECLARE_XAM_EXPORT1(XamLoaderGetMediaInfo, kNone, kStub); dword_result_t XamContentLaunchImageFromFileInternal_entry( lpstring_t image_location, lpstring_t xex_name, dword_t unk) { +#if XE_PLATFORM_IOS + if (kernel_state()->IsTitleStopRequestedIOS()) { + kernel_state()->TerminateTitle(); + return X_ERROR_FUNCTION_FAILED; + } +#endif // XE_PLATFORM_IOS + const std::string image_path = static_cast(image_location); const std::string xex_name_ = static_cast(xex_name); @@ -934,6 +941,13 @@ DECLARE_XAM_EXPORT1(XamContentLaunchImageFromFileInternal, kContent, kStub); dword_result_t XamContentLaunchImageInternal_entry(lpvoid_t content_data_ptr, lpstring_t xex_path) { +#if XE_PLATFORM_IOS + if (kernel_state()->IsTitleStopRequestedIOS()) { + kernel_state()->TerminateTitle(); + return X_ERROR_FUNCTION_FAILED; + } +#endif // XE_PLATFORM_IOS + XCONTENT_AGGREGATE_DATA content_data = *content_data_ptr.as(); // title_id is written into first 8 characters of filename diff --git a/src/xenia/kernel/xam/xam_info.cc b/src/xenia/kernel/xam/xam_info.cc index 36b452342..37ad7e8d4 100644 --- a/src/xenia/kernel/xam/xam_info.cc +++ b/src/xenia/kernel/xam/xam_info.cc @@ -292,6 +292,13 @@ dword_result_t XamLoaderGetLaunchData_entry(lpvoid_t buffer_ptr, DECLARE_XAM_EXPORT1(XamLoaderGetLaunchData, kNone, kSketchy); void XamLoaderLaunchTitle_entry(lpstring_t raw_name_ptr, dword_t flags) { +#if XE_PLATFORM_IOS + if (kernel_state()->IsTitleStopRequestedIOS()) { + kernel_state()->TerminateTitle(); + return; + } +#endif // XE_PLATFORM_IOS + auto xam = kernel_state()->GetKernelModule("xam.xex"); auto& loader_data = xam->loader_data(); @@ -376,13 +383,12 @@ void XamLoaderLaunchTitle_entry(lpstring_t raw_name_ptr, dword_t flags) { auto on_launch_new_title = kernel_state()->emulator()->on_launch_new_title(); if (on_launch_new_title) { - XELOGI("XamLoaderLaunchTitle: spawning new title process"); + XELOGI("XamLoaderLaunchTitle: requesting new title launch"); on_launch_new_title(xe::path_to_utf8(host_path), launch_path, loader_data.launch_flags, launch_data_hex); - // Callback calls quick_exit, so we don't reach here } - // Terminate if callback wasn't set + // Stop the current title after the callback queues or handles launch. XELOGI("XamLoaderLaunchTitle: terminating to launch new title"); kernel_state()->TerminateTitle(); // This function does not return diff --git a/src/xenia/kernel/xam/xam_ui.cc b/src/xenia/kernel/xam/xam_ui.cc index 21aa08a33..c3b702ffe 100644 --- a/src/xenia/kernel/xam/xam_ui.cc +++ b/src/xenia/kernel/xam/xam_ui.cc @@ -8,6 +8,9 @@ */ #include "xenia/kernel/xam/xam_ui.h" + +#include + #include "xenia/app/emulator_window.h" #include "xenia/base/png_utils.h" #include "xenia/base/system.h" @@ -20,6 +23,9 @@ #include "xenia/ui/imgui_dialog.h" #include "xenia/ui/imgui_drawer.h" #include "xenia/ui/imgui_guest_notification.h" +#if XE_PLATFORM_IOS +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#endif #include "xenia/kernel/xam/ui/create_profile_ui.h" #include "xenia/kernel/xam/ui/game_achievements_ui.h" @@ -391,9 +397,41 @@ static dword_result_t XamShowMessageBoxUi( buttons.push_back("OK"); } +#if XE_PLATFORM_IOS + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context && + !(flags & XMBox_PASSCODEMODE || flags & XMBox_VERIFYPASSCODEMODE)) { + const uint32_t selected_active_button = + buttons.empty() + ? 0 + : std::min(uint32_t(active_button), uint32_t(buttons.size() - 1)); + auto run = [ios_context, title_str = std::string(title), + text_str = std::string(text), + buttons_copy = std::vector(buttons), + selected_active_button, result_ptr]() -> X_RESULT { + uint32_t selected_button = selected_active_button; + if (!ios_context->PromptMessageBoxUI(title_str, text_str, buttons_copy, + selected_active_button, + &selected_button)) { + XELOGW( + "iOS: failed to show native message box prompt, defaulting to " + "button {}.", + selected_active_button); + selected_button = selected_active_button; + } + result_ptr->ButtonPressed = selected_button; + return X_ERROR_SUCCESS; + }; + return xeXamDispatchHeadless(run, overlapped); + } +#endif // XE_PLATFORM_IOS + X_RESULT result; - if (cvars::headless) { - // Auto-pick the focused button. + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { + // Auto-pick the focused button (headless or no UI drawer available). auto run = [result_ptr, active_button]() -> X_RESULT { result_ptr->ButtonPressed = static_cast(active_button); return X_ERROR_SUCCESS; @@ -491,8 +529,10 @@ dword_result_t XNotifyQueueUI_entry(dword_t exnq, dword_t dwUserIndex, const Emulator* emulator = kernel_state()->emulator(); xe::ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); - new xe::ui::XNotifyWindow(imgui_drawer, "", displayText, dwUserIndex, - position_id); + if (imgui_drawer) { + new xe::ui::XNotifyWindow(imgui_drawer, "", displayText, dwUserIndex, + position_id); + } // XNotifyQueueUI -> XNotifyQueueUIEx -> XMsgProcessRequest -> // XMsgStartIORequestEx & XMsgInProcessCall @@ -514,7 +554,44 @@ dword_result_t XamShowKeyboardUI_entry( auto buffer_size = static_cast(buffer_length) * 2; X_RESULT result; - if (cvars::headless) { + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { +#if XE_PLATFORM_IOS + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + std::string title_str = title ? xe::to_utf8(title.value()) : ""; + std::string desc_str = + description ? xe::to_utf8(description.value()) : ""; + std::string def_text_str = + default_text ? xe::to_utf8(default_text.value()) : ""; + auto run = [ios_context, title_str = std::string(title_str), + desc_str = std::string(desc_str), + def_text_str = std::string(def_text_str), buffer, + buffer_length]() -> X_RESULT { + std::string typed_text; + bool cancelled = true; + if (!ios_context->PromptKeyboardUI(title_str, desc_str, def_text_str, + &typed_text, &cancelled)) { + auto default_utf16 = xe::to_utf16(def_text_str); + string_util::copy_and_swap_truncating(buffer, default_utf16, + buffer_length); + return X_ERROR_SUCCESS; + } + if (cancelled) { + return X_ERROR_CANCELLED; + } + auto text_utf16 = xe::to_utf16(typed_text); + string_util::copy_and_swap_truncating(buffer, text_utf16, + buffer_length); + return X_ERROR_SUCCESS; + }; + result = xeXamDispatchHeadless(run, overlapped); + return result; + } +#endif // XE_PLATFORM_IOS + auto run = [default_text, buffer, buffer_length, buffer_size]() -> X_RESULT { // Redirect default_text back into the buffer. @@ -593,9 +670,52 @@ dword_result_t XamShowDeviceSelectorUI_entry( } std::vector devices = ListStorageDevices(); + std::string title = "Select storage device"; + std::string desc = ""; - if (cvars::headless || !cvars::storage_selection_dialog) { - // Default to the first storage device (HDD) if headless. + cxxopts::OptionNames buttons; + for (auto& device_info : devices) { + buttons.push_back(to_utf8(device_info->name)); + } + buttons.push_back("Cancel"); + + if (cvars::headless || !cvars::storage_selection_dialog || + !kernel_state()->emulator()->imgui_drawer()) { +#if XE_PLATFORM_IOS + if (!cvars::headless && cvars::storage_selection_dialog) { + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadless( + [device_id_ptr, devices, + buttons = std::vector(buttons), + title = std::string(title), desc = std::string(desc), + ios_context]() -> X_RESULT { + if (devices.empty()) { + return X_ERROR_CANCELLED; + } + uint32_t selected_button = 0; + if (!ios_context->PromptMessageBoxUI(title, desc, buttons, 0, + &selected_button)) { + XELOGW( + "iOS: storage selector prompt unavailable, defaulting to " + "first device."); + selected_button = 0; + } + if (selected_button >= devices.size()) { + return X_ERROR_CANCELLED; + } + const DummyDeviceInfo* device_info = devices.at(selected_button); + *device_id_ptr = static_cast(device_info->device_id); + return X_ERROR_SUCCESS; + }, + overlapped); + } + } +#endif // XE_PLATFORM_IOS + // Default to the first storage device (HDD) if headless / no UI. return xeXamDispatchHeadless( [device_id_ptr, devices]() -> X_RESULT { if (devices.empty()) { @@ -620,15 +740,6 @@ dword_result_t XamShowDeviceSelectorUI_entry( return X_ERROR_SUCCESS; }; - std::string title = "Select storage device"; - std::string desc = ""; - - cxxopts::OptionNames buttons; - for (auto& device_info : devices) { - buttons.push_back(to_utf8(device_info->name)); - } - buttons.push_back("Cancel"); - const Emulator* emulator = kernel_state()->emulator(); xe::ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); xe::hid::InputSystem* input_system = emulator->input_system(); @@ -639,26 +750,42 @@ dword_result_t XamShowDeviceSelectorUI_entry( DECLARE_XAM_EXPORT1(XamShowDeviceSelectorUI, kUI, kImplemented); void XamShowDirtyDiscErrorUI_entry(dword_t user_index) { - if (cvars::headless) { - assert_always(); - exit(1); - return; - } - + (void)user_index; + XELOGE("XamShowDirtyDiscErrorUI called"); std::string title = "Disc Read Error"; std::string desc = "There's been an issue reading content from the game disc.\nThis is " "likely caused by bad or unimplemented file IO calls."; + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { +#if XE_PLATFORM_IOS + if (!cvars::headless) { + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + xeXamDispatchHeadlessAsync([ios_context, title = std::string(title), + desc = std::string(desc)]() { + uint32_t selected_button = 0; + ios_context->PromptMessageBoxUI(title, desc, {"OK"}, 0, + &selected_button); + }); + return; + } + } +#endif // XE_PLATFORM_IOS + XELOGE("Disc Read Error (no UI available) - continuing without abort"); + xeXamDispatchHeadlessAsync([]() {}); + return; + } + const Emulator* emulator = kernel_state()->emulator(); xe::ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); xe::hid::InputSystem* input_system = emulator->input_system(); - xeXamDispatchDialog( + xeXamDispatchDialogAsync( new MessageBoxDialog(imgui_drawer, input_system, title, desc, {"OK"}, 0), - [](MessageBoxDialog*) -> X_RESULT { return X_ERROR_SUCCESS; }, 0); - // This is death, and should never return. - // TODO(benvanik): cleaner exit. - exit(1); + [](MessageBoxDialog*) -> void {}); } DECLARE_XAM_EXPORT1(XamShowDirtyDiscErrorUI, kUI, kImplemented); @@ -712,10 +839,6 @@ dword_result_t XamShowMarketplaceUIEx_entry(dword_t user_index, dword_t ui_type, return X_ERROR_NO_SUCH_USER; } - if (cvars::headless) { - return xeXamDispatchHeadlessAsync([]() {}); - } - bool is_xbla_unlock_offer = (offer_id == ((uint64_t(kernel_state()->title_id()) << 32) | 1ull)); @@ -797,6 +920,34 @@ dword_result_t XamShowMarketplaceUIEx_entry(dword_t user_index, dword_t ui_type, break; } + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { +#if XE_PLATFORM_IOS + if (!cvars::headless) { + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadlessAsync( + [ios_context, title = std::string(title), desc = std::string(desc), + buttons = std::vector(buttons), ui_type, + is_xbla_unlock_offer]() { + uint32_t selected_button = 0; + ios_context->PromptMessageBoxUI(title, desc, buttons, 0, + &selected_button); + if (ui_type == X_MARKETPLACE_ENTRYPOINT::ContentItem && + is_xbla_unlock_offer && selected_button == 0) { + cvars::license_mask = 1; + kernel_state()->BroadcastNotification( + kXNotificationLiveContentInstalled, 0); + } + }); + } + } +#endif // XE_PLATFORM_IOS + return xeXamDispatchHeadlessAsync([]() {}); + } + desc += "\nNote that since Xenia cannot access Xbox Marketplace, any DLC must be " "installed manually using File -> Install Content."; @@ -854,17 +1005,6 @@ dword_result_t XamShowMarketplaceDownloadItemsUI_entry( return X_ERROR_NO_SUCH_USER; } - if (cvars::headless) { - return xeXamDispatchHeadless( - [hresult_ptr]() -> X_RESULT { - if (hresult_ptr) { - *hresult_ptr = X_E_SUCCESS; - } - return X_ERROR_SUCCESS; - }, - overlapped); - } - auto close = [hresult_ptr](MessageBoxDialog* dialog) -> X_RESULT { if (hresult_ptr) { // TODO @@ -900,6 +1040,40 @@ dword_result_t XamShowMarketplaceDownloadItemsUI_entry( "must " "be installed manually using File -> Install Content."; + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { +#if XE_PLATFORM_IOS + if (!cvars::headless) { + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadless( + [ios_context, title = std::string(title), desc = std::string(desc), + buttons = std::vector(buttons), + hresult_ptr]() -> X_RESULT { + uint32_t selected_button = 0; + ios_context->PromptMessageBoxUI(title, desc, buttons, 0, + &selected_button); + if (hresult_ptr) { + *hresult_ptr = X_E_SUCCESS; + } + return X_ERROR_SUCCESS; + }, + overlapped); + } + } +#endif // XE_PLATFORM_IOS + return xeXamDispatchHeadless( + [hresult_ptr]() -> X_RESULT { + if (hresult_ptr) { + *hresult_ptr = X_E_SUCCESS; + } + return X_ERROR_SUCCESS; + }, + overlapped); + } + const Emulator* emulator = kernel_state()->emulator(); xe::ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); xe::hid::InputSystem* input_system = emulator->input_system(); @@ -1001,7 +1175,48 @@ X_RESULT xeXamShowSigninUI(uint32_t user_index, uint32_t users_needed, return X_ERROR_INVALID_PARAMETER; } - if (cvars::headless) { + if (cvars::headless || !kernel_state()->emulator()->imgui_drawer()) { + auto* profile_manager = kernel_state()->xam_state()->profile_manager(); + if (!profile_manager) { + return X_ERROR_FUNCTION_FAILED; + } + +#if XE_PLATFORM_IOS + // iOS doesn't use ImGui dialogs. Ask the native UIKit layer to present a + // sign-in/profile selection prompt for title requests. + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadless( + [ios_context, user_index, users_needed]() -> X_RESULT { + if (ios_context->PromptSignInUI(user_index, users_needed)) { + return X_ERROR_SUCCESS; + } + // Fallback when native prompt couldn't be shown. + std::map xuids; + for (uint32_t i = 0; i < XUserMaxUserCount; i++) { + UserProfile* profile = + kernel_state()->xam_state()->GetUserProfile(i); + if (profile) { + xuids[i] = profile->xuid(); + if (xuids.size() >= users_needed) { + break; + } + } + } + if (xuids.empty()) { + return X_ERROR_NO_SUCH_USER; + } + kernel_state()->xam_state()->profile_manager()->LoginMultiple( + xuids); + return X_ERROR_SUCCESS; + }, + 0); + } +#endif // XE_PLATFORM_IOS + return xeXamDispatchHeadlessAsync([users_needed]() { std::map xuids; @@ -1035,7 +1250,19 @@ X_RESULT xeXamShowCreateProfileUIEx(uint32_t user_index, dword_t flag, Emulator* emulator = kernel_state()->emulator(); xe::ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer(); - if (cvars::headless) { + if (cvars::headless || !imgui_drawer) { +#if XE_PLATFORM_IOS + if (!cvars::headless) { + auto& app_context = emulator->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadlessAsync([ios_context, user_index]() { + ios_context->PromptSignInUI(user_index, 1); + }); + } + } +#endif // XE_PLATFORM_IOS return X_ERROR_SUCCESS; } @@ -1110,6 +1337,22 @@ dword_result_t XamShowAchievementsUI_entry(dword_t user_index, xe::ui::ImGuiDrawer* imgui_drawer = kernel_state()->emulator()->imgui_drawer(); + if (!imgui_drawer) { +#if XE_PLATFORM_IOS + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + uint32_t prompt_user_index = user_index.value(); + return xeXamDispatchHeadlessAsync([ios_context, prompt_user_index, + proper_title_id]() { + ios_context->PromptAchievementsUI(prompt_user_index, proper_title_id); + }); + } +#endif // XE_PLATFORM_IOS + return X_ERROR_SUCCESS; + } xe::hid::InputSystem* input_system = kernel_state()->emulator()->input_system(); @@ -1129,6 +1372,24 @@ dword_result_t XamShowGamerCardUI_entry(dword_t user_index) { xe::ui::ImGuiDrawer* imgui_drawer = kernel_state()->emulator()->imgui_drawer(); + if (!imgui_drawer) { +#if XE_PLATFORM_IOS + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadlessAsync([ios_context]() { + uint32_t selected_button = 0; + ios_context->PromptMessageBoxUI( + "Gamer Card", + "Gamer card UI is not implemented yet in the iOS build.", {"OK"}, 0, + &selected_button); + }); + } +#endif // XE_PLATFORM_IOS + return X_ERROR_SUCCESS; + } auto close = [](ui::GamercardUI* dialog) -> void {}; return xeXamDispatchDialogAsync( @@ -1146,6 +1407,24 @@ dword_result_t XamShowEditProfileUI_entry(dword_t user_index) { xe::ui::ImGuiDrawer* imgui_drawer = kernel_state()->emulator()->imgui_drawer(); + if (!imgui_drawer) { +#if XE_PLATFORM_IOS + auto& app_context = + kernel_state()->emulator()->display_window()->app_context(); + auto* ios_context = + dynamic_cast(&app_context); + if (ios_context) { + return xeXamDispatchHeadlessAsync([ios_context]() { + uint32_t selected_button = 0; + ios_context->PromptMessageBoxUI( + "Edit Profile", + "Profile editing UI is not implemented yet in the iOS build.", + {"OK"}, 0, &selected_button); + }); + } +#endif // XE_PLATFORM_IOS + return X_ERROR_SUCCESS; + } auto close = [](ui::GamercardUI* dialog) -> void {}; return xeXamDispatchDialogAsync( diff --git a/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc b/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc index 3d08a503c..87e057ca4 100644 --- a/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc +++ b/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc @@ -106,6 +106,11 @@ uint32_t ExCreateThread(xe::be* handle_ptr, uint32_t stack_size, // DWORD CreationFlags // 0x80? auto kernel_state_var = kernel_state(); +#if XE_PLATFORM_IOS + if (kernel_state_var->IsTitleStopRequestedIOS()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS // xenia_assert((creation_flags & 2) == 0); // creating system thread? if (creation_flags & 2) { XELOGE("Guest is creating a system thread!"); diff --git a/src/xenia/kernel/xobject.cc b/src/xenia/kernel/xobject.cc index 327e69c00..1b97059ea 100644 --- a/src/xenia/kernel/xobject.cc +++ b/src/xenia/kernel/xobject.cc @@ -31,6 +31,101 @@ namespace xe { namespace kernel { +#if XE_PLATFORM_IOS +namespace { + +constexpr std::chrono::milliseconds kTitleStopWaitPollIntervalIOS(10); + +XThread* CurrentXThreadIOS() { + if (!XThread::IsInThread()) { + return nullptr; + } + + return XThread::GetCurrentThread(); +} + +bool IsTitleStopPollThreadIOS(XThread* thread) { + return thread && + (thread->is_guest_thread() || thread->can_debugger_suspend()); +} + +XThread* CurrentTitleStopPollThreadIOS() { + auto* current_thread = CurrentXThreadIOS(); + return IsTitleStopPollThreadIOS(current_thread) ? current_thread : nullptr; +} + +bool ShouldPollTitleStopIOS(KernelState* kernel_state) { + return kernel_state && CurrentTitleStopPollThreadIOS(); +} + +void ExitCurrentTitleStopThreadIfRequestedIOS(KernelState* kernel_state) { + auto* current_thread = CurrentTitleStopPollThreadIOS(); + if (!current_thread || !kernel_state || + !kernel_state->IsTitleStopRequestedIOS()) { + return; + } + + current_thread->Exit(0); +} + +std::chrono::milliseconds TitleStopWaitSliceIOS( + bool infinite_timeout, std::chrono::steady_clock::time_point deadline) { + if (infinite_timeout) { + return kTitleStopWaitPollIntervalIOS; + } + + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::chrono::milliseconds::zero(); + } + + auto remaining = + std::chrono::duration_cast(deadline - now); + return std::max(std::chrono::milliseconds(1), + std::min(kTitleStopWaitPollIntervalIOS, remaining)); +} + +bool TitleStopWaitTimedOutIOS(xe::threading::WaitResult result) { + return result == xe::threading::WaitResult::kTimeout; +} + +bool TitleStopWaitTimedOutIOS( + const std::pair& result) { + return result.first == xe::threading::WaitResult::kTimeout; +} + +template +auto WaitWithTitleStopPollIOS(KernelState* kernel_state, + std::chrono::milliseconds timeout, + WaitCall wait_call) { + if (!ShouldPollTitleStopIOS(kernel_state) || + timeout == std::chrono::milliseconds::zero()) { + return wait_call(timeout); + } + + const bool infinite_timeout = timeout == std::chrono::milliseconds::max(); + const auto deadline = infinite_timeout + ? std::chrono::steady_clock::time_point::max() + : std::chrono::steady_clock::now() + timeout; + + while (true) { + ExitCurrentTitleStopThreadIfRequestedIOS(kernel_state); + + auto result = wait_call(TitleStopWaitSliceIOS(infinite_timeout, deadline)); + if (!TitleStopWaitTimedOutIOS(result)) { + return result; + } + + ExitCurrentTitleStopThreadIfRequestedIOS(kernel_state); + if (!infinite_timeout && std::chrono::steady_clock::now() >= deadline) { + return result; + } + } +} + +} // namespace +#endif // XE_PLATFORM_IOS + XObject::XObject(Type type) : kernel_state_(nullptr), pointer_ref_count_(1), type_(type) { handles_.reserve(10); @@ -75,10 +170,20 @@ Memory* XObject::memory() const { return kernel_state_->memory(); } XObject::Type XObject::type() const { return type_; } void XObject::RetainHandle() { +#if XE_PLATFORM_IOS + if (handles_.empty()) { + return; + } +#endif // XE_PLATFORM_IOS kernel_state_->object_table()->RetainHandle(handles_[0]); } bool XObject::ReleaseHandle() { +#if XE_PLATFORM_IOS + if (handles_.empty()) { + return false; + } +#endif // XE_PLATFORM_IOS // FIXME: Return true when handle is actually released. return kernel_state_->object_table()->ReleaseHandle(handles_[0]) == X_STATUS_SUCCESS; @@ -304,7 +409,16 @@ X_STATUS XObject::Wait(uint32_t wait_reason, uint32_t processor_mode, X_KTHREAD* kthread = WaitEnter(wait_reason, processor_mode, alertable); auto result = +#if XE_PLATFORM_IOS + WaitWithTitleStopPollIOS( + kernel_state_, timeout_ms, + [wait_handle, alertable](std::chrono::milliseconds wait_timeout) { + return xe::threading::Wait(wait_handle, alertable ? true : false, + wait_timeout); + }); +#else xe::threading::Wait(wait_handle, alertable ? true : false, timeout_ms); +#endif // XE_PLATFORM_IOS switch (result) { case xe::threading::WaitResult::kSuccess: @@ -443,6 +557,9 @@ X_STATUS XObject::WaitMultiple(uint32_t count, XObject** objects, wait_handles[i] = objects[i]->GetWaitHandle(); assert_not_null(wait_handles[i]); } +#if XE_PLATFORM_IOS + KernelState* wait_kernel_state = count ? objects[0]->kernel_state_ : nullptr; +#endif // XE_PLATFORM_IOS if (GuestScheduler::enabled() && count > 0 && XThread::GetCurrentFiberThread()) { @@ -521,8 +638,18 @@ X_STATUS XObject::WaitMultiple(uint32_t count, XObject** objects, X_STATUS status; uint32_t boost_increment = 0; if (wait_type) { - auto result = xe::threading::WaitAny(wait_handles, count, - alertable ? true : false, timeout_ms); + auto result = +#if XE_PLATFORM_IOS + WaitWithTitleStopPollIOS(wait_kernel_state, timeout_ms, + [&](std::chrono::milliseconds wait_timeout) { + return xe::threading::WaitAny( + wait_handles, count, + alertable ? true : false, wait_timeout); + }); +#else + xe::threading::WaitAny(wait_handles, count, alertable ? true : false, + timeout_ms); +#endif // XE_PLATFORM_IOS switch (result.first) { case xe::threading::WaitResult::kSuccess: objects[result.second]->WaitCallback(); @@ -545,8 +672,18 @@ X_STATUS XObject::WaitMultiple(uint32_t count, XObject** objects, break; } } else { - auto result = xe::threading::WaitAll(wait_handles, count, - alertable ? true : false, timeout_ms); + auto result = +#if XE_PLATFORM_IOS + WaitWithTitleStopPollIOS(wait_kernel_state, timeout_ms, + [&](std::chrono::milliseconds wait_timeout) { + return xe::threading::WaitAll( + wait_handles, count, + alertable ? true : false, wait_timeout); + }); +#else + xe::threading::WaitAll(wait_handles, count, alertable ? true : false, + timeout_ms); +#endif // XE_PLATFORM_IOS switch (result) { case xe::threading::WaitResult::kSuccess: for (uint32_t i = 0; i < count; i++) { diff --git a/src/xenia/kernel/xobject.h b/src/xenia/kernel/xobject.h index 07ddf316c..4f777db1b 100644 --- a/src/xenia/kernel/xobject.h +++ b/src/xenia/kernel/xobject.h @@ -15,6 +15,7 @@ #include #include +#include "xenia/base/platform.h" #include "xenia/base/threading.h" #include "xenia/kernel/kernel.h" #include "xenia/memory.h" @@ -184,7 +185,13 @@ class XObject { Type type() const; // Returns the primary handle of this object. - X_HANDLE handle() const { return handles_[0]; } + X_HANDLE handle() const { +#if XE_PLATFORM_IOS + return handles_.empty() ? 0 : handles_[0]; +#else + return handles_[0]; +#endif // XE_PLATFORM_IOS + } // Returns all associated handles with this object. std::vector handles() const { return handles_; } diff --git a/src/xenia/kernel/xthread.cc b/src/xenia/kernel/xthread.cc index dc3f2819e..3833083a9 100644 --- a/src/xenia/kernel/xthread.cc +++ b/src/xenia/kernel/xthread.cc @@ -9,7 +9,10 @@ #include "xenia/kernel/xthread.h" -#if XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_MAC +#include +#include + +#if XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_APPLE #include #endif #if !XE_PLATFORM_WIN32 @@ -34,6 +37,13 @@ DEFINE_bool(ignore_thread_priorities, false, UPDATE_from_bool(ignore_thread_priorities, 2026, 4, 9, 12, true); DEFINE_bool(ignore_thread_affinities, true, "Ignores game-specified thread affinities.", "Kernel"); +#if XE_PLATFORM_IOS +DEFINE_bool( + ios_guest_threads_user_initiated_qos, false, + "Run guest XThreads at user-initiated QoS on iOS. This is an iOS " + "scheduler experiment for devices where guest CPU execution falls behind.", + "iOS"); +#endif // XE_PLATFORM_IOS #if 0 DEFINE_int64(stack_size_multiplier_hack, 1, @@ -334,6 +344,12 @@ void XThread::FreeStack() { } X_STATUS XThread::Create() { +#if XE_PLATFORM_IOS + if (kernel_state_ && kernel_state_->IsTitleStopRequestedIOS()) { + return X_STATUS_PROCESS_IS_TERMINATING; + } +#endif // XE_PLATFORM_IOS + // Thread kernel object. if (!CreateNative()) { XELOGW("Unable to allocate thread object"); @@ -433,6 +449,13 @@ X_STATUS XThread::Create() { // Always retain when starting - the thread owns itself until exited. RetainHandle(); +#if XE_PLATFORM_IOS + // iOS title-exit reset clears the object table while native thread lambdas + // may still be unwinding. Keep the XThread object itself alive independently + // of its guest handle until the native thread function has returned. + Retain(); + object_ref ios_thread_lifetime(this); +#endif // XE_PLATFORM_IOS if (GuestScheduler::enabled() && !is_host_thread()) { // Cooperative fiber path: the guest thread runs on a fiber the scheduler @@ -567,10 +590,27 @@ X_STATUS XThread::Exit(int exit_code) { xboxkrnl::xeKeKfReleaseSpinLock(cpu_context, &kprocess->thread_list_spinlock, old_irql); - kernel_state()->OnThreadExit(this); +#if XE_PLATFORM_IOS + const bool ios_title_stop_host_thread = + !guest_thread_ && kernel_state_ && + kernel_state_->IsTitleStopRequestedIOS(); + if (ios_title_stop_host_thread) { + emulator()->processor()->OnThreadExit(thread_id_); + } else { +#endif // XE_PLATFORM_IOS + kernel_state()->OnThreadExit(this); +#if XE_PLATFORM_IOS + } +#endif // XE_PLATFORM_IOS // Notify processor of our exit. +#if XE_PLATFORM_IOS + if (!ios_title_stop_host_thread) { + emulator()->processor()->OnThreadExit(thread_id_); + } +#else emulator()->processor()->OnThreadExit(thread_id_); +#endif // XE_PLATFORM_IOS if (fiber_) { // On a fiber, Thread::Exit() would kill the shared dispatch thread. Wake @@ -584,13 +624,13 @@ X_STATUS XThread::Exit(int exit_code) { } // NOTE: unless PlatformExit fails, expect it to never return! -#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_MAC) +#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_APPLE) current_xthread_tls_ = nullptr; current_thread_ = nullptr; xe::Profiler::ThreadExit(); #endif running_ = false; -#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_MAC) +#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_APPLE) ReleaseHandle(); #endif @@ -630,7 +670,7 @@ X_STATUS XThread::Terminate(int exit_code) { xe::threading::Thread::Exit(exit_code); } else if (thread_) { thread_->Terminate(exit_code); -#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_MAC) +#if !(XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_APPLE) ReleaseHandle(); #endif } else { @@ -1161,6 +1201,15 @@ X_STATUS XThread::Delay(uint32_t processor_mode, uint32_t alertable, timeout_ms = Clock::ScaleGuestDurationMillis(timeout_ms); +#if XE_PLATFORM_IOS + auto exit_if_title_stop_requested = [this]() { + if (XThread::IsInThread(this) && kernel_state_ && + kernel_state_->IsTitleStopRequestedIOS()) { + Exit(0); + } + }; +#endif // XE_PLATFORM_IOS + if (fiber_) { // Cooperative path: yield/park the fiber instead of sleeping the dispatch // host thread. A zero timeout is a plain yield, otherwise park until the @@ -1181,6 +1230,22 @@ X_STATUS XThread::Delay(uint32_t processor_mode, uint32_t alertable, } if (alertable) { +#if XE_PLATFORM_IOS + if ((guest_thread_ || can_debugger_suspend()) && timeout_ms > 0) { + uint32_t remaining_ms = timeout_ms; + while (remaining_ms > 0) { + const uint32_t slice_ms = std::min(remaining_ms, 10); + auto result = + xe::threading::AlertableSleep(std::chrono::milliseconds(slice_ms)); + if (result == xe::threading::SleepResult::kAlerted) { + return X_STATUS_USER_APC; + } + exit_if_title_stop_requested(); + remaining_ms -= slice_ms; + } + return X_STATUS_SUCCESS; + } +#endif // XE_PLATFORM_IOS auto result = xe::threading::AlertableSleep(std::chrono::milliseconds(timeout_ms)); switch (result) { @@ -1197,8 +1262,25 @@ X_STATUS XThread::Delay(uint32_t processor_mode, uint32_t alertable, } else { xe::threading::MaybeYield(); } +#if XE_PLATFORM_IOS + exit_if_title_stop_requested(); +#endif // XE_PLATFORM_IOS } else { - xe::threading::Sleep(std::chrono::milliseconds(timeout_ms)); +#if XE_PLATFORM_IOS + if (guest_thread_ || can_debugger_suspend()) { + uint32_t remaining_ms = timeout_ms; + while (remaining_ms > 0) { + const uint32_t slice_ms = std::min(remaining_ms, 10); + xe::threading::Sleep(std::chrono::milliseconds(slice_ms)); + exit_if_title_stop_requested(); + remaining_ms -= slice_ms; + } + } else { +#endif // XE_PLATFORM_IOS + xe::threading::Sleep(std::chrono::milliseconds(timeout_ms)); +#if XE_PLATFORM_IOS + } +#endif // XE_PLATFORM_IOS } } @@ -1369,45 +1451,69 @@ object_ref XThread::Restore(KernelState* kernel_state, // Always retain when starting - the thread owns itself until exited. thread->RetainHandle(); +#if XE_PLATFORM_IOS + thread->Retain(); + object_ref ios_thread_lifetime(thread); +#endif // XE_PLATFORM_IOS xe::threading::Thread::CreationParameters params; params.create_suspended = true; // Not done restoring yet. params.stack_size = 16_MiB; - thread->thread_ = xe::threading::Thread::Create(params, [thread, state]() { - // Set thread ID override. This is used by logging. - xe::threading::set_current_thread_id(thread->handle()); + thread->thread_ = xe::threading::Thread::Create( + params, [thread, state +#if XE_PLATFORM_IOS + , + ios_thread_lifetime = std::move(ios_thread_lifetime) +#endif // XE_PLATFORM_IOS + ]() { + // Set thread ID override. This is used by logging. + xe::threading::set_current_thread_id(thread->handle()); - // Set name immediately, if we have one. - thread->thread_->set_name(thread->name()); + // Set name immediately, if we have one. + thread->thread_->set_name(thread->name()); - // Profiler needs to know about the thread. - xe::Profiler::ThreadEnter(thread->name().c_str()); + // Profiler needs to know about the thread. + xe::Profiler::ThreadEnter(thread->name().c_str()); - current_xthread_tls_ = thread; - current_thread_ = thread; + current_xthread_tls_ = thread; + current_thread_ = thread; - // Acquire any mutants - for (auto mutant : thread->pending_mutant_acquires_) { - uint64_t timeout = 0; - auto status = mutant->Wait(0, 0, 0, &timeout); - assert_true(status == X_STATUS_SUCCESS); - } - thread->pending_mutant_acquires_.clear(); + // Acquire any mutants + for (auto mutant : thread->pending_mutant_acquires_) { + uint64_t timeout = 0; + auto status = mutant->Wait(0, 0, 0, &timeout); + assert_true(status == X_STATUS_SUCCESS); + } + thread->pending_mutant_acquires_.clear(); - // Execute user code. - thread->running_ = true; + // Execute user code. + thread->running_ = true; -#if XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_MAC - pthread_cleanup_push(HostThreadExitCleanupThunk, thread); - uint32_t pc = state.context.pc; - thread->kernel_state_->processor()->ExecuteRaw(thread->thread_state_, pc); - pthread_cleanup_pop(1); +#if XE_PLATFORM_LINUX || XE_PLATFORM_ANDROID || XE_PLATFORM_APPLE + pthread_cleanup_push(HostThreadExitCleanupThunk, thread); + uint32_t pc = state.context.pc; +#if XE_PLATFORM_IOS + const bool execute_thread = + !(thread->kernel_state_ && + thread->kernel_state_->IsTitleStopRequestedIOS()); +#else + const bool execute_thread = true; +#endif // XE_PLATFORM_IOS + if (execute_thread) { + thread->kernel_state_->processor()->ExecuteRaw( + thread->thread_state_, pc); +#if XE_PLATFORM_IOS + } else { + thread->Exit(0); +#endif // XE_PLATFORM_IOS + } + pthread_cleanup_pop(1); #else uint32_t pc = state.context.pc; thread->kernel_state_->processor()->ExecuteRaw(thread->thread_state_, pc); thread->OnHostThreadExitCleanup(); #endif - }); + }); assert_not_null(thread->thread_); // Notify processor we were recreated. diff --git a/src/xenia/memory.cc b/src/xenia/memory.cc index 517b2e45c..0d6c6bed5 100644 --- a/src/xenia/memory.cc +++ b/src/xenia/memory.cc @@ -13,7 +13,7 @@ #include #include -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE #include #endif @@ -107,8 +107,8 @@ void CrashDump() { } static inline bool ShouldSkipHostCommit(const BaseHeap& heap) { -#if XE_PLATFORM_MAC - // On macOS ARM64 the host page size is 16 KB, and mprotect-based "commit" +#if XE_PLATFORM_APPLE + // On Apple ARM64 the host page size is 16 KB, and mprotect-based "commit" // creates fragmentation in the 0..512 MB parent physical heap. if (heap.heap_type() == HeapType::kGuestPhysical && heap.heap_base() == 0x0 && xe::memory::page_size() > 0x1000) { @@ -215,9 +215,9 @@ bool Memory::Initialize() { return false; } -#if XE_PLATFORM_MAC - // On macOS, reserve a contiguous region chosen by the OS, then map views - // into it at fixed offsets. +#if XE_PLATFORM_APPLE + // On Apple platforms, reserve a contiguous region chosen by the OS, then map + // views into it at fixed offsets. if (MapViewsMac()) { XELOGE("Unable to find a continuous block in the 64bit address space."); assert_always(); @@ -386,7 +386,7 @@ static const struct { 0x0000000100000000ull, }, }; -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE int Memory::MapViewsMac() { assert_true(xe::countof(map_info) == xe::countof(views_.all_views)); @@ -432,7 +432,7 @@ int Memory::MapViewsMac() { return 0; } -#endif // XE_PLATFORM_MAC +#endif // XE_PLATFORM_APPLE int Memory::MapViews(uint8_t* mapping_base) { assert_true(xe::countof(map_info) == xe::countof(views_.all_views)); @@ -1650,7 +1650,7 @@ bool BaseHeap::Protect(uint32_t address, uint32_t size, uint32_t protect, if (old_protect) { *old_protect = page_table_[start_page_number].current_protect; } -#if !XE_PLATFORM_MAC +#if !XE_PLATFORM_APPLE return false; #endif } diff --git a/src/xenia/memory.h b/src/xenia/memory.h index 69a594180..3d6c7b64d 100644 --- a/src/xenia/memory.h +++ b/src/xenia/memory.h @@ -568,7 +568,7 @@ class Memory { void* context); private: -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE int MapViewsMac(); #endif int MapViews(uint8_t* mapping_base); diff --git a/src/xenia/patcher/patch_db.cc b/src/xenia/patcher/patch_db.cc index 1d997695e..f3d6f7667 100644 --- a/src/xenia/patcher/patch_db.cc +++ b/src/xenia/patcher/patch_db.cc @@ -340,6 +340,10 @@ const BundledIndex& GetBundledIndex() { } // namespace +std::vector EnumerateBundledPatches() { + return GetBundledIndex().all; +} + std::vector EnumerateBundledPatchesForTitle( uint32_t title_id) { std::vector out; diff --git a/src/xenia/patcher/patch_db.h b/src/xenia/patcher/patch_db.h index 085c61310..19122034d 100644 --- a/src/xenia/patcher/patch_db.h +++ b/src/xenia/patcher/patch_db.h @@ -145,6 +145,7 @@ struct BundledPatchFile { PatchFileEntry entry; }; +std::vector EnumerateBundledPatches(); std::vector EnumerateBundledPatchesForTitle( uint32_t title_id); diff --git a/src/xenia/ui/CMakeLists.txt b/src/xenia/ui/CMakeLists.txt index 8b8d17583..76851fdab 100644 --- a/src/xenia/ui/CMakeLists.txt +++ b/src/xenia/ui/CMakeLists.txt @@ -1,16 +1,18 @@ add_library(xenia-ui STATIC) xe_platform_sources(xenia-ui ${CMAKE_CURRENT_SOURCE_DIR}) # Demo files are standalone executables in XENIA_BUILD_MISC; windowed_app_main_* -# files are added explicitly per-target. wx files are excluded on Android (no -# xe-wxWidgets target there). +# files are added explicitly per-target. wx files are excluded where no +# xe-wxWidgets target exists. file(GLOB _ui_exclude "${CMAKE_CURRENT_SOURCE_DIR}/*_demo.cc" "${CMAKE_CURRENT_SOURCE_DIR}/windowed_app_main_*.cc" ) -if(CMAKE_SYSTEM_NAME STREQUAL "Android") +if(CMAKE_SYSTEM_NAME STREQUAL "Android" OR XE_PLATFORM_IOS) file(GLOB _wx_exclude "${CMAKE_CURRENT_SOURCE_DIR}/*_wx.cc" "${CMAKE_CURRENT_SOURCE_DIR}/*_wx.h" + "${CMAKE_CURRENT_SOURCE_DIR}/icon_decode.cc" + "${CMAKE_CURRENT_SOURCE_DIR}/icon_decode.h" "${CMAKE_CURRENT_SOURCE_DIR}/wx_locale.cc" "${CMAKE_CURRENT_SOURCE_DIR}/wx_locale.h" ) @@ -22,7 +24,7 @@ if(_ui_exclude AND _ui_srcs) set_target_properties(xenia-ui PROPERTIES SOURCES "${_ui_srcs}") endif() target_link_libraries(xenia-ui PUBLIC xenia-base) -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android") +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android" AND NOT XE_PLATFORM_IOS) target_link_libraries(xenia-ui PUBLIC xe-wxWidgets) endif() if(WIN32) @@ -32,7 +34,12 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") # fontconfig: imgui_drawer.cc font lookup. # GTK3: XCB handle extraction in window_wx.cc (gtk_widget_realize, etc.). target_link_libraries(xenia-ui PRIVATE xcb X11 X11-xcb fontconfig ${GTK3_LIBRARIES}) -elseif(APPLE) +elseif(XE_PLATFORM_IOS) + set_source_files_properties( + ${CMAKE_CURRENT_SOURCE_DIR}/audio_helper.cc + PROPERTIES LANGUAGE OBJCXX + ) +elseif(XE_PLATFORM_MACOS) # xe_platform_sources globs *.cc only; .mm sources need explicit listing. target_sources(xenia-ui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/surface_mac.mm @@ -54,7 +61,7 @@ xe_embed_binary_assets(xenia-ui # time, then bundle the staging tree into the binary. wx_locale.cc decodes # the bundle and feeds it to wxTranslations via a custom loader, so no # .mo files ship alongside the exe. -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android") +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android" AND NOT XE_PLATFORM_IOS) set(_xe_locale_stage "${CMAKE_CURRENT_BINARY_DIR}/locale_staged") file(GLOB _xe_po_files "${PROJECT_SOURCE_DIR}/assets/locale/*/xenia.po") diff --git a/src/xenia/ui/achievement_notification_payload.h b/src/xenia/ui/achievement_notification_payload.h new file mode 100644 index 000000000..1fd2f19d3 --- /dev/null +++ b/src/xenia/ui/achievement_notification_payload.h @@ -0,0 +1,35 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_ACHIEVEMENT_NOTIFICATION_PAYLOAD_H_ +#define XENIA_UI_ACHIEVEMENT_NOTIFICATION_PAYLOAD_H_ + +#include +#include +#include + +namespace xe { +namespace ui { + +struct AchievementNotificationPayload { + uint8_t user_index = 0; + uint8_t position_id = 2; + uint64_t xuid = 0; + uint32_t title_id = 0; + uint32_t achievement_id = 0; + uint32_t gamerscore = 0; + std::string title; + std::string description; + std::vector icon_data; +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_ACHIEVEMENT_NOTIFICATION_PAYLOAD_H_ diff --git a/src/xenia/ui/config_helpers.h b/src/xenia/ui/config_helpers.h index f213b687a..59aba52b5 100644 --- a/src/xenia/ui/config_helpers.h +++ b/src/xenia/ui/config_helpers.h @@ -55,7 +55,7 @@ inline const std::vector& GetCvarAliases() { {"gpu", "any", "gpu", "vulkan"}, {"apu", "any", "apu", "sdl"}, {"hid", "any", "hid", "sdl"}, -#elif XE_PLATFORM_MAC +#elif XE_PLATFORM_APPLE {"gpu", "any", "gpu", "metal"}, {"apu", "any", "apu", "sdl"}, {"hid", "any", "hid", "sdl"}, diff --git a/src/xenia/ui/ios/CMakeLists.txt b/src/xenia/ui/ios/CMakeLists.txt new file mode 100644 index 000000000..6ad15063d --- /dev/null +++ b/src/xenia/ui/ios/CMakeLists.txt @@ -0,0 +1,172 @@ +add_library(xenia-ui-ios STATIC) + +target_sources(xenia-ui-ios PRIVATE + app/ios_controller_navigation_coordinator.h + app/ios_controller_navigation_coordinator.mm + app/ios_app_delegate.h + app/ios_app_delegate.mm + app/ios_in_game_menu_overlay.h + app/ios_in_game_menu_overlay.mm + app/ios_landscape_navigation_controller.h + app/ios_landscape_navigation_controller.mm + app/ios_main_view_controller.h + app/ios_main_view_controller.mm + app/ios_pause_dashboard_view_controller.h + app/ios_pause_dashboard_view_controller.mm + app/ios_pause_workspace_view_controller.h + app/ios_pause_workspace_view_controller.mm + app/ios_display_settings_view_controller.h + app/ios_display_settings_view_controller.mm + app/ios_window_layout.h + app/ios_window_layout.mm + app/ios_window_position_overlay.h + app/ios_window_position_overlay.mm + app/windowed_app_context_ios.h + app/windowed_app_context_ios.mm + game/file_picker_ios.mm + game/ios_achievement_notification_presenter.h + game/ios_achievement_notification_presenter.mm + game/ios_achievements_view_controller.h + game/ios_achievements_view_controller.mm + game/ios_metal_view.h + game/ios_metal_view.mm + game/surface_ios.h + game/surface_ios.mm + game/window_ios.h + game/window_ios.mm + launcher/ios_compat_data.h + launcher/ios_compat_data.mm + launcher/ios_compat_cache.h + launcher/ios_compat_cache.mm + launcher/ios_compat_fetch.h + launcher/ios_compat_fetch.mm + launcher/ios_compat_formatting.h + launcher/ios_compat_formatting.mm + launcher/ios_compat_report_cells.h + launcher/ios_compat_report_cells.mm + launcher/ios_compat_report_submission.h + launcher/ios_compat_report_submission.mm + launcher/ios_compat_report_view_controller.h + launcher/ios_compat_report_view_controller.mm + launcher/ios_compat_summary.h + launcher/ios_compat_summary.mm + launcher/ios_content_management.h + launcher/ios_content_management.mm + launcher/ios_document_import_coordinator.h + launcher/ios_document_import_coordinator.mm + launcher/ios_external_url.h + launcher/ios_external_url.mm + launcher/ios_game_art.h + launcher/ios_game_art.mm + launcher/ios_game_compatibility_cells.h + launcher/ios_game_compatibility_cells.mm + launcher/ios_game_compatibility_discussion_controller.h + launcher/ios_game_compatibility_discussion_controller.mm + launcher/ios_game_compatibility_hero_view.h + launcher/ios_game_compatibility_hero_view.mm + launcher/ios_game_compatibility_view_controller.h + launcher/ios_game_compatibility_view_controller.mm + launcher/ios_game_actions_view_controller.h + launcher/ios_game_actions_view_controller.mm + launcher/ios_game_picker_view_controller.h + launcher/ios_game_picker_view_controller.mm + launcher/ios_game_content_view_controller.h + launcher/ios_game_content_view_controller.mm + launcher/ios_game_disc_view_controller.h + launcher/ios_game_disc_view_controller.mm + launcher/ios_game_library.h + launcher/ios_game_library.mm + launcher/ios_game_library_store.h + launcher/ios_game_library_store.mm + launcher/ios_game_patches_view_controller.h + launcher/ios_game_patches_view_controller.mm + launcher/ios_game_tile_cell.h + launcher/ios_game_tile_cell.mm + launcher/ios_launcher_overlay_view.h + launcher/ios_launcher_overlay_view.mm + settings/ios_choice_list_view_controller.h + settings/ios_choice_list_view_controller.mm + settings/ios_config_builder.h + settings/ios_config_builder.mm + settings/ios_config_catalog.h + settings/ios_config_catalog.mm + settings/ios_config_models.h + settings/ios_config_storage.h + settings/ios_config_storage.mm + settings/ios_config_view_controller.h + settings/ios_config_view_controller.mm + settings/ios_debug_settings_view_controller.h + settings/ios_debug_settings_view_controller.mm + settings/ios_quick_settings_view_controller.h + settings/ios_quick_settings_view_controller.mm + settings/ios_log_view_controller.h + settings/ios_log_view_controller.mm + settings/ios_profile_view_controller.h + settings/ios_profile_view_controller.mm + settings/ios_settings_hub_view_controller.h + settings/ios_settings_hub_view_controller.mm + shared/apple_theme_tokens.cc + shared/apple_theme_tokens.h + shared/apple_ui_navigation.cc + shared/apple_ui_navigation.h + shared/ios_system_utils.h + shared/ios_system_utils.mm + shared/ios_status_toast.h + shared/ios_status_toast.mm + shared/ios_hero_glow_palette.h + shared/ios_hero_glow_palette.mm + shared/ios_theme.h + shared/ios_theme.mm + shared/ios_theme_controls.h + shared/ios_theme_controls.mm + shared/ios_view_helpers.h + shared/ios_view_helpers.mm + touch/touch_control_shell_view_ios.h + touch/touch_control_shell_view_ios.mm + touch/touch_controls_overlay_ios.h + touch/touch_controls_overlay_helpers_ios.h + touch/touch_controls_overlay_helpers_ios.mm + touch/touch_controls_overlay_ios.mm + touch/touch_layout_library_controller_ios.h + touch/touch_layout_library_controller_ios.mm + touch/touch_layout_library_ios.h + touch/touch_layout_library_ios.mm + touch/touch_layout_library_view_ios.h + touch/touch_layout_library_view_ios.mm + touch/touch_layout_editor_view_controller_ios.h + touch/touch_layout_editor_view_controller_ios.mm + touch/touch_layout_store_ios.h + touch/touch_layout_store_ios.mm + touch/touch_layout_ui_coordinator_ios.h + touch/touch_layout_ui_coordinator_ios.mm + touch/touch_overlay_edit_chrome_ios.h + touch/touch_overlay_edit_chrome_ios.mm + touch/touch_overlay_edit_history_ios.h + touch/touch_overlay_edit_history_ios.mm + touch/touch_overlay_geometry_ios.h + touch/touch_overlay_geometry_ios.mm + touch/touch_overlay_style_ios.h + touch/touch_overlay_style_ios.mm +) + +target_compile_options(xenia-ui-ios PRIVATE + $<$:-Wno-nullability-completeness> +) + +target_link_libraries(xenia-ui-ios PUBLIC + xenia-base + xenia-hid-touch-model + xenia-ui + "-framework AVFoundation" + "-framework AudioToolbox" + "-framework Foundation" + "-framework GameController" + "-framework Metal" + "-framework MetalKit" + "-framework PhotosUI" + "-framework QuartzCore" + "-framework UIKit" + "-framework UniformTypeIdentifiers" +) + +xe_target_defaults(xenia-ui-ios) diff --git a/src/xenia/ui/ios/app/ios_app_delegate.h b/src/xenia/ui/ios/app/ios_app_delegate.h new file mode 100644 index 000000000..ee2917916 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_app_delegate.h @@ -0,0 +1,35 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_DELEGATE_H_ +#define XENIA_UI_IOS_APP_DELEGATE_H_ + +#import + +@class XeniaViewController; + +// Application + scene delegate pair that own the iOS lifecycle: create the +// XeniaWindowedAppContext, mount the XeniaViewController as the window's +// root, route external xenios:// URLs and rebroadcast scene-becomes-active +// into the StikDebug auto-handoff probe. +@interface XeniaAppDelegate : UIResponder +@property(nonatomic, strong) UIWindow* window; +- (BOOL)bootstrapApplicationWithWindow:(UIWindow*)window + launchURL:(NSURL*)launch_url + sourceTag:(const char*)source_tag; +- (XeniaViewController*)xeniaViewController; +- (BOOL)handleExternalLaunchURL:(NSURL*)url sourceTag:(const char*)source_tag; +- (void)evaluateAutomaticStikDebugJITHandoffIfNeeded:(const char*)source_tag; +@end + +@interface XeniaSceneDelegate : UIResponder +@property(nonatomic, strong) UIWindow* window; +@end + +#endif // XENIA_UI_IOS_APP_DELEGATE_H_ diff --git a/src/xenia/ui/ios/app/ios_app_delegate.mm b/src/xenia/ui/ios/app/ios_app_delegate.mm new file mode 100644 index 000000000..80dc38a08 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_app_delegate.mm @@ -0,0 +1,353 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_app_delegate.h" + +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/logging.h" +#include "xenia/ui/windowed_app.h" + +#import "xenia/ui/ios/app/windowed_app_context_ios.h" +#import "xenia/ui/ios/game/ios_metal_view.h" +#import "xenia/ui/ios/app/ios_main_view_controller.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +DECLARE_path(log_file); + +@implementation XeniaAppDelegate { + std::unique_ptr app_context_; + std::unique_ptr app_; +} + +- (void)evaluateAutomaticStikDebugJITHandoffIfNeeded:(const char*)source_tag { + XeniaViewController* view_controller = [self xeniaViewController]; + if (!view_controller) { + XELOGW("iOS: Skipping automatic StikDebug handoff evaluation ({}) with no view controller", + source_tag ? source_tag : "unknown"); + return; + } + XELOGI("iOS: Evaluating automatic StikDebug handoff ({})", source_tag ? source_tag : "unknown"); + [view_controller evaluateAutomaticStikDebugJITHandoffIfNeeded]; +} + +- (BOOL)application:(UIApplication*)application + didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { + (void)application; + NSURL* launch_url = nil; + if (launchOptions) { + launch_url = [launchOptions objectForKey:UIApplicationLaunchOptionsURLKey]; + } + if (@available(iOS 13.0, *)) { + if (launch_url) { + XELOGI("iOS: launch URL deferred to scene bootstrap"); + } + return YES; + } + + UIWindow* legacy_window = + [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]] autorelease]; + return [self bootstrapApplicationWithWindow:legacy_window + launchURL:launch_url + sourceTag:"launchOptions"]; +} + +- (BOOL)bootstrapApplicationWithWindow:(UIWindow*)window + launchURL:(NSURL*)launch_url + sourceTag:(const char*)source_tag { + if (!window) { + XELOGE("iOS: Cannot bootstrap app without a UIWindow"); + return NO; + } + UIWindow* previous_window = self.window; + self.window = window; + if (app_) { + UIViewController* existing_root = previous_window.rootViewController; + if (!self.window.rootViewController && existing_root) { + self.window.rootViewController = existing_root; + [self.window makeKeyAndVisible]; + } + if (launch_url) { + [self handleExternalLaunchURL:launch_url sourceTag:source_tag ? source_tag : "bootstrap"]; + } + [self + evaluateAutomaticStikDebugJITHandoffIfNeeded:source_tag ? source_tag : "bootstrapExisting"]; + return YES; + } + + // Initialize cvars with no arguments on iOS (arguments come from config). + int argc = 1; + char arg0[] = "xenios"; + char* argv[] = {arg0}; + char** argv_ptr = argv; + cvar::ParseLaunchArguments(argc, argv_ptr, "", {}); + + // Create the app context. + app_context_ = std::make_unique(); + + // Set up the UIKit window and view controller FIRST, so the Metal view + // is available when the app initializes. + XeniaViewController* vc = [[XeniaViewController alloc] init]; + self.window.rootViewController = vc; + [self.window makeKeyAndVisible]; + xe_request_current_orientation(vc); + + // Force layout so the Metal view is created. + [vc.view layoutIfNeeded]; + + // Store the Metal view and view controller in the app context for + // iOSWindow to use. + app_context_->set_metal_view(vc.metalView); + app_context_->set_view_controller(vc); + app_context_->set_touch_runtime_model([vc touchRuntimeModel]); + vc.appContext = app_context_.get(); + app_context_->set_signin_ui_prompt_callback([vc](uint32_t user_index, uint32_t users_needed) { + if ([NSThread isMainThread]) { + [vc presentSystemSigninPromptForUserIndex:user_index + usersNeeded:users_needed + completion:nil]; + return true; + } + __block BOOL success = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + [vc presentSystemSigninPromptForUserIndex:user_index + usersNeeded:users_needed + completion:^(BOOL prompt_success) { + success = prompt_success; + dispatch_semaphore_signal(sem); + }]; + }); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + return success ? true : false; + }); + app_context_->set_achievements_ui_prompt_callback([vc](uint32_t user_index, uint32_t title_id) { + if ([NSThread isMainThread]) { + [vc presentAchievementsForUserIndex:user_index titleID:title_id completion:nil]; + return true; + } + __block BOOL success = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + [vc presentAchievementsForUserIndex:user_index + titleID:title_id + completion:^(BOOL prompt_success) { + success = prompt_success; + dispatch_semaphore_signal(sem); + }]; + }); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + return success ? true : false; + }); + app_context_->set_achievement_notification_callback( + [vc](const xe::ui::AchievementNotificationPayload& payload) { + if (!vc) { + return false; + } + xe::ui::AchievementNotificationPayload payload_copy = payload; + dispatch_async(dispatch_get_main_queue(), ^{ + [vc presentAchievementNotification:payload_copy]; + }); + return true; + }); + app_context_->set_keyboard_prompt_callback( + [vc](const std::string& title, const std::string& description, + const std::string& default_text, std::string* text_out, bool* cancelled_out) { + if ([NSThread isMainThread]) { + if (cancelled_out) { + *cancelled_out = true; + } + return false; + } + __block BOOL cancelled = YES; + __block NSString* typed_text = @""; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + [vc presentSystemKeyboardPromptWithTitle:ToNSString(title) + description:ToNSString(description) + defaultText:ToNSString(default_text) + completion:^(BOOL prompt_cancelled, NSString* prompt_text) { + cancelled = prompt_cancelled; + typed_text = prompt_text ?: @""; + dispatch_semaphore_signal(sem); + }]; + }); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + if (text_out) { + *text_out = std::string([typed_text UTF8String]); + } + if (cancelled_out) { + *cancelled_out = cancelled ? true : false; + } + return true; + }); + app_context_->set_game_exited_callback([vc]() { [vc showLauncherOverlay]; }); + app_context_->set_profile_services_ready_callback([vc]() { + dispatch_async(dispatch_get_main_queue(), ^{ + [vc refreshSignedInProfileUI]; + if ([vc.statusLabel.text isEqualToString:@"Initializing profile services..."]) { + vc.statusLabel.text = @""; + } + }); + }); + XELOGI("iOS: Metal view ready ({}x{})", + static_cast(vc.metalView.bounds.size.width * vc.metalView.contentScaleFactor), + static_cast(vc.metalView.bounds.size.height * vc.metalView.contentScaleFactor)); + + // Create and initialize the Xenia app. + app_ = xe::ui::GetWindowedAppCreator()(*app_context_); + if (cvars::log_file.empty()) { + cvars::log_file = xe_get_ios_documents_path() / "xenia.log"; + } + xe::InitializeLogging(app_->GetName()); + + if (!app_->OnInitialize()) { + XELOGE("iOS: App initialization failed"); + return NO; + } + + [vc refreshImportedGames]; + [vc refreshSignedInProfileUI]; + if (vc.appContext) { + vc.statusLabel.text = @"Initializing profile services..."; + vc.appContext->LaunchGame(std::string()); + } + + if (launch_url) { + [self handleExternalLaunchURL:launch_url sourceTag:source_tag ? source_tag : "bootstrap"]; + } + [self evaluateAutomaticStikDebugJITHandoffIfNeeded:source_tag ? source_tag : "bootstrapNew"]; + + XELOGI("iOS: Application launched successfully"); + return YES; +} + +- (XeniaViewController*)xeniaViewController { + UIViewController* root_view_controller = self.window.rootViewController; + if ([root_view_controller isKindOfClass:[XeniaViewController class]]) { + return (XeniaViewController*)root_view_controller; + } + return nil; +} + +- (BOOL)handleExternalLaunchURL:(NSURL*)url sourceTag:(const char*)source_tag { + if (!url) { + return NO; + } + + NSString* absolute_url = [url absoluteString]; + XELOGI("iOS: Received app URL ({}) {}", source_tag ? source_tag : "unknown", + absolute_url ? [absolute_url UTF8String] : ""); + + XeniaViewController* view_controller = [self xeniaViewController]; + if (!view_controller) { + XELOGW("iOS: Ignoring URL launch; root view controller unavailable"); + return NO; + } + + BOOL handled = [view_controller handleExternalLaunchURL:url]; + if (!handled) { + XELOGW("iOS: URL launch was not handled"); + } + return handled ? YES : NO; +} + +- (BOOL)application:(UIApplication*)application + openURL:(NSURL*)url + options:(NSDictionary*)options { + (void)application; + (void)options; + return [self handleExternalLaunchURL:url sourceTag:"openURL"]; +} + +- (void)applicationDidBecomeActive:(UIApplication*)application { + (void)application; + [self evaluateAutomaticStikDebugJITHandoffIfNeeded:"applicationDidBecomeActive"]; +} + +- (UISceneConfiguration*)application:(UIApplication*)application + configurationForConnectingSceneSession:(UISceneSession*)connectingSceneSession + options:(UISceneConnectionOptions*)options { + (void)application; + (void)options; + if (@available(iOS 13.0, *)) { + UISceneConfiguration* configuration = + [[[UISceneConfiguration alloc] initWithName:@"Default Configuration" + sessionRole:connectingSceneSession.role] autorelease]; + configuration.delegateClass = [XeniaSceneDelegate class]; + return configuration; + } + return nil; +} + +- (UIInterfaceOrientationMask)application:(UIApplication*)application + supportedInterfaceOrientationsForWindow:(UIWindow*)window { + UIViewController* root = window.rootViewController; + if (root) { + return [root supportedInterfaceOrientations]; + } + return UIInterfaceOrientationMaskAll; +} + +- (void)applicationWillTerminate:(UIApplication*)application { + XELOGI("iOS lifecycle: applicationWillTerminate"); + if (app_) { + app_->InvokeOnDestroy(); + app_.reset(); + } + app_context_.reset(); +} + +@end + +@implementation XeniaSceneDelegate + +- (void)scene:(UIScene*)scene + willConnectToSession:(UISceneSession*)session + options:(UISceneConnectionOptions*)connectionOptions { + (void)session; + if (![scene isKindOfClass:[UIWindowScene class]]) { + XELOGE("iOS: scene connection ignored because scene is not a UIWindowScene"); + return; + } + + UIWindowScene* window_scene = (UIWindowScene*)scene; + UIWindow* scene_window = [[[UIWindow alloc] initWithWindowScene:window_scene] autorelease]; + self.window = scene_window; + + NSURL* launch_url = xe_first_open_url_context_url(connectionOptions.URLContexts); + XeniaAppDelegate* app_delegate = (XeniaAppDelegate*)[UIApplication sharedApplication].delegate; + if (!app_delegate || ![app_delegate bootstrapApplicationWithWindow:scene_window + launchURL:launch_url + sourceTag:"sceneConnect"]) { + XELOGE("iOS: scene bootstrap failed"); + } +} + +- (void)scene:(UIScene*)scene openURLContexts:(NSSet*)URLContexts { + (void)scene; + NSURL* url = xe_first_open_url_context_url(URLContexts); + XeniaAppDelegate* app_delegate = (XeniaAppDelegate*)[UIApplication sharedApplication].delegate; + if (app_delegate) { + [app_delegate handleExternalLaunchURL:url sourceTag:"sceneOpenURL"]; + } +} + +- (void)sceneDidBecomeActive:(UIScene*)scene { + (void)scene; + XeniaAppDelegate* app_delegate = (XeniaAppDelegate*)[UIApplication sharedApplication].delegate; + if (app_delegate) { + [app_delegate evaluateAutomaticStikDebugJITHandoffIfNeeded:"sceneDidBecomeActive"]; + } +} + +@end diff --git a/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.h b/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.h new file mode 100644 index 000000000..f0b7f0434 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.h @@ -0,0 +1,72 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_CONTROLLER_NAVIGATION_COORDINATOR_H_ +#define XENIA_UI_IOS_APP_IOS_CONTROLLER_NAVIGATION_COORDINATOR_H_ + +#ifdef __OBJC__ + +#import + +#include "xenia/hid/input.h" +#include "xenia/ui/ios/shared/apple_ui_navigation.h" + +#import "xenia/ui/ios/app/ios_in_game_menu_overlay.h" + +@protocol XeniaIOSControllerNavigationHost + +- (BOOL)controllerNavigationLauncherVisible; +- (BOOL)controllerNavigationLauncherActionsEnabled; +- (NSInteger)controllerNavigationGameCount; +- (NSInteger)controllerNavigationLauncherColumnCount; +- (NSInteger)controllerNavigationLauncherPageStep; +- (BOOL)controllerNavigationGameRunning; +- (BOOL)controllerNavigationInGameMenuVisible; +- (BOOL)controllerNavigationInGameMenuActionEnabled:(XeniaIOSInGameMenuAction)action; +- (UIViewController*)controllerNavigationPresentedController; +- (BOOL)controllerNavigationHasConnectedController; +- (BOOL)controllerNavigationReadEmulatorControllerState:(xe::hid::X_INPUT_STATE*)outState; + +- (void)controllerNavigationApplyFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll; +- (void)controllerNavigationApplyLauncherFocusEnabled:(BOOL)enabled + settingsFocused:(BOOL)settingsFocused + profileFocused:(BOOL)profileFocused + importFocused:(BOOL)importFocused + libraryFocusActive:(BOOL)libraryFocusActive; +- (void)controllerNavigationApplyInGameMenuFocusEnabled:(BOOL)enabled + focusedAction:(XeniaIOSInGameMenuAction)focusedAction; + +- (void)controllerNavigationOpenSettings; +- (void)controllerNavigationOpenProfile; +- (void)controllerNavigationImportGame; +- (void)controllerNavigationManageGameAtIndex:(NSInteger)index; +- (void)controllerNavigationLaunchGameAtIndex:(NSInteger)index; +- (void)controllerNavigationShowInGameMenu; +- (void)controllerNavigationHideInGameMenu; +- (void)controllerNavigationPerformInGameMenuAction:(XeniaIOSInGameMenuAction)action; + +@end + +@interface XeniaIOSControllerNavigationCoordinator : NSObject + +@property(nonatomic, readonly) NSInteger focusedGameIndex; + +- (instancetype)initWithHost:(id)host; +- (void)start; +- (void)invalidate; +- (void)setFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll; +- (void)refreshLauncherFocus; +- (void)refreshInGameFocus; +- (void)focusDefaultInGameAction; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_APP_IOS_CONTROLLER_NAVIGATION_COORDINATOR_H_ diff --git a/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.mm b/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.mm new file mode 100644 index 000000000..8942e86a2 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_controller_navigation_coordinator.mm @@ -0,0 +1,859 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_controller_navigation_coordinator.h" + +#import + +#include +#include + +#import "xenia/ui/ios/settings/ios_log_view_controller.h" + +namespace { + +using IOSFocusNodeId = xe::ui::apple::FocusNodeId; +static constexpr IOSFocusNodeId kLauncherFocusSettings = 1; +static constexpr IOSFocusNodeId kLauncherFocusProfile = 2; +static constexpr IOSFocusNodeId kLauncherFocusImport = 3; +static constexpr IOSFocusNodeId kLauncherFocusLibrary = 4; +static constexpr IOSFocusNodeId kInGameFocusResume = 101; +static constexpr IOSFocusNodeId kInGameFocusEdit = 102; +static constexpr IOSFocusNodeId kInGameFocusAchievements = 103; +static constexpr IOSFocusNodeId kInGameFocusDisplay = 104; +static constexpr IOSFocusNodeId kInGameFocusSettings = 105; +static constexpr IOSFocusNodeId kInGameFocusLog = 106; +static constexpr IOSFocusNodeId kInGameFocusExit = 107; +static constexpr IOSFocusNodeId kInGameFocusGraphics = 108; + +uint64_t GetNowMs() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +int16_t ToThumbAxis(float value) { + const float clamped = std::clamp(value, -1.0f, 1.0f); + return static_cast(clamped * 32767.0f); +} + +uint8_t ToTriggerAxis(float value) { + const float clamped = std::clamp(value, 0.0f, 1.0f); + return static_cast(clamped * 255.0f); +} + +XeniaIOSInGameMenuAction InGameActionForFocus(IOSFocusNodeId focus) { + switch (focus) { + case kInGameFocusResume: + return XeniaIOSInGameMenuActionResume; + case kInGameFocusEdit: + return XeniaIOSInGameMenuActionEditControls; + case kInGameFocusAchievements: + return XeniaIOSInGameMenuActionAchievements; + case kInGameFocusDisplay: + return XeniaIOSInGameMenuActionDisplay; + case kInGameFocusSettings: + return XeniaIOSInGameMenuActionSettings; + case kInGameFocusLog: + return XeniaIOSInGameMenuActionLiveLog; + case kInGameFocusExit: + return XeniaIOSInGameMenuActionExit; + case kInGameFocusGraphics: + return XeniaIOSInGameMenuActionGraphics; + default: + return XeniaIOSInGameMenuActionNone; + } +} + +BOOL TriggerBarButtonItem(UIBarButtonItem* item) { + if (!item || !item.enabled || !item.target || !item.action) { + return NO; + } + return [[UIApplication sharedApplication] sendAction:item.action + to:item.target + from:item + forEvent:nil]; +} + +} // namespace + +@implementation XeniaIOSControllerNavigationCoordinator { + id _host; + NSTimer* _timer; + xe::ui::apple::ControllerNavigationMapper _mapper; + xe::ui::apple::FocusGraph _launcherFocusGraph; + xe::ui::apple::FocusGraph _inGameFocusGraph; + NSInteger _focusedGameIndex; + BOOL _launcherLibraryFocusActive; + BOOL _navigationWasEnabled; + uint32_t _nativeControllerPacketNumber; +} + +- (instancetype)initWithHost:(id)host { + if (!(self = [super init])) { + return nil; + } + _host = host; + _focusedGameIndex = -1; + _launcherLibraryFocusActive = NO; + _navigationWasEnabled = NO; + _nativeControllerPacketNumber = 0; + _mapper.Reset(); + return self; +} + +- (void)dealloc { + [self invalidate]; + [super dealloc]; +} + +- (NSInteger)focusedGameIndex { + return _focusedGameIndex; +} + +- (void)start { + if (_timer) { + return; + } + _timer = [[NSTimer scheduledTimerWithTimeInterval:(1.0 / 60.0) + target:self + selector:@selector(poll:) + userInfo:nil + repeats:YES] retain]; + _timer.tolerance = 0.01; +} + +- (void)invalidate { + [_timer invalidate]; + [_timer release]; + _timer = nil; +} + +- (void)setFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll { + const NSInteger game_count = [_host controllerNavigationGameCount]; + if (game_count <= 0) { + index = -1; + } else { + if (index < 0) { + index = 0; + } + const NSInteger max_index = game_count - 1; + if (index > max_index) { + index = max_index; + } + } + _focusedGameIndex = index; + [_host controllerNavigationApplyFocusedGameIndex:_focusedGameIndex scroll:scroll]; +} + +- (void)refreshLauncherFocus { + if (![_host controllerNavigationHasConnectedController]) { + _navigationWasEnabled = NO; + _mapper.Reset(); + _launcherFocusGraph.Clear(); + _inGameFocusGraph.Clear(); + [self applyLauncherFocusVisuals]; + [self applyInGameMenuFocusVisuals]; + return; + } + _navigationWasEnabled = YES; + [self rebuildLauncherFocusGraph]; + [self applyLauncherFocusVisuals]; +} + +- (void)refreshInGameFocus { + if (![_host controllerNavigationHasConnectedController]) { + _navigationWasEnabled = NO; + _mapper.Reset(); + _launcherFocusGraph.Clear(); + _inGameFocusGraph.Clear(); + [self applyLauncherFocusVisuals]; + [self applyInGameMenuFocusVisuals]; + return; + } + _navigationWasEnabled = YES; + [self rebuildInGameFocusGraph]; + [self applyInGameMenuFocusVisuals]; +} + +- (void)focusDefaultInGameAction { + [self rebuildInGameFocusGraph]; + _inGameFocusGraph.SetCurrent(kInGameFocusResume); + [self applyInGameMenuFocusVisuals]; +} + +- (void)rebuildLauncherFocusGraph { + IOSFocusNodeId previous_focus = _launcherFocusGraph.current(); + _launcherFocusGraph.Clear(); + + const BOOL launcher_visible = [_host controllerNavigationLauncherVisible]; + const BOOL actions_enabled = + launcher_visible && [_host controllerNavigationLauncherActionsEnabled]; + + xe::ui::apple::FocusNode settings; + settings.id = kLauncherFocusSettings; + settings.right = kLauncherFocusProfile; + settings.down = kLauncherFocusImport; + settings.enabled = actions_enabled; + + xe::ui::apple::FocusNode profile; + profile.id = kLauncherFocusProfile; + profile.left = kLauncherFocusSettings; + profile.down = kLauncherFocusImport; + profile.enabled = actions_enabled; + + xe::ui::apple::FocusNode import_button; + import_button.id = kLauncherFocusImport; + import_button.left = kLauncherFocusProfile; + import_button.right = kLauncherFocusLibrary; + import_button.up = kLauncherFocusSettings; + import_button.down = kLauncherFocusLibrary; + import_button.enabled = actions_enabled; + + xe::ui::apple::FocusNode library; + library.id = kLauncherFocusLibrary; + library.left = kLauncherFocusImport; + library.up = kLauncherFocusImport; + library.enabled = [_host controllerNavigationGameCount] > 0; + + _launcherFocusGraph.AddOrUpdateNode(settings); + _launcherFocusGraph.AddOrUpdateNode(profile); + _launcherFocusGraph.AddOrUpdateNode(import_button); + _launcherFocusGraph.AddOrUpdateNode(library); + + if (previous_focus != xe::ui::apple::kInvalidFocusNodeId) { + _launcherFocusGraph.SetCurrent(previous_focus); + } +} + +- (void)rebuildInGameFocusGraph { + IOSFocusNodeId previous_focus = _inGameFocusGraph.current(); + _inGameFocusGraph.Clear(); + + xe::ui::apple::FocusNode resume; + resume.id = kInGameFocusResume; + resume.down = kInGameFocusEdit; + resume.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionResume]; + + // Row 1 left: Edit Controls + xe::ui::apple::FocusNode edit_controls; + edit_controls.id = kInGameFocusEdit; + edit_controls.up = kInGameFocusResume; + edit_controls.down = kInGameFocusDisplay; + edit_controls.right = kInGameFocusGraphics; + edit_controls.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionEditControls]; + + // Row 1 right: Graphics + xe::ui::apple::FocusNode graphics; + graphics.id = kInGameFocusGraphics; + graphics.up = kInGameFocusResume; + graphics.down = kInGameFocusSettings; + graphics.left = kInGameFocusEdit; + graphics.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionGraphics]; + + // Row 2 left: Display + xe::ui::apple::FocusNode display_node; + display_node.id = kInGameFocusDisplay; + display_node.up = kInGameFocusEdit; + display_node.down = kInGameFocusAchievements; + display_node.right = kInGameFocusSettings; + display_node.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionDisplay]; + + // Row 2 right: Settings + xe::ui::apple::FocusNode settings; + settings.id = kInGameFocusSettings; + settings.up = kInGameFocusGraphics; + settings.down = kInGameFocusExit; + settings.left = kInGameFocusDisplay; + settings.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionSettings]; + + // Row 3 left: Achievements + xe::ui::apple::FocusNode achievements; + achievements.id = kInGameFocusAchievements; + achievements.up = kInGameFocusDisplay; + achievements.down = kInGameFocusLog; + achievements.right = kInGameFocusExit; + achievements.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionAchievements]; + + // Row 3 right: Exit + xe::ui::apple::FocusNode exit_node; + exit_node.id = kInGameFocusExit; + exit_node.up = kInGameFocusSettings; + exit_node.down = kInGameFocusLog; + exit_node.left = kInGameFocusAchievements; + exit_node.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionExit]; + + // Footer: Live Log + xe::ui::apple::FocusNode log; + log.id = kInGameFocusLog; + log.up = kInGameFocusAchievements; + log.down = kInGameFocusResume; + log.enabled = [_host controllerNavigationInGameMenuActionEnabled: + XeniaIOSInGameMenuActionLiveLog]; + + _inGameFocusGraph.AddOrUpdateNode(resume); + _inGameFocusGraph.AddOrUpdateNode(edit_controls); + _inGameFocusGraph.AddOrUpdateNode(graphics); + _inGameFocusGraph.AddOrUpdateNode(display_node); + _inGameFocusGraph.AddOrUpdateNode(settings); + _inGameFocusGraph.AddOrUpdateNode(achievements); + _inGameFocusGraph.AddOrUpdateNode(exit_node); + _inGameFocusGraph.AddOrUpdateNode(log); + + if (previous_focus != xe::ui::apple::kInvalidFocusNodeId) { + _inGameFocusGraph.SetCurrent(previous_focus); + } +} + +- (void)applyLauncherFocusVisuals { + if (!_navigationWasEnabled) { + _launcherLibraryFocusActive = NO; + [_host controllerNavigationApplyLauncherFocusEnabled:NO + settingsFocused:NO + profileFocused:NO + importFocused:NO + libraryFocusActive:NO]; + return; + } + + IOSFocusNodeId current_focus = _launcherFocusGraph.current(); + BOOL settings_focused = current_focus == kLauncherFocusSettings; + BOOL profile_focused = current_focus == kLauncherFocusProfile; + BOOL import_focused = current_focus == kLauncherFocusImport; + BOOL library_focused = current_focus == kLauncherFocusLibrary; + + if (library_focused && _focusedGameIndex < 0 && + [_host controllerNavigationGameCount] > 0) { + [self setFocusedGameIndex:0 scroll:NO]; + } + + _launcherLibraryFocusActive = library_focused; + [_host controllerNavigationApplyLauncherFocusEnabled:YES + settingsFocused:settings_focused + profileFocused:profile_focused + importFocused:import_focused + libraryFocusActive:_launcherLibraryFocusActive]; +} + +- (void)applyInGameMenuFocusVisuals { + if (![_host controllerNavigationInGameMenuVisible] || !_navigationWasEnabled) { + [_host controllerNavigationApplyInGameMenuFocusEnabled:NO + focusedAction:XeniaIOSInGameMenuActionNone]; + return; + } + + [_host controllerNavigationApplyInGameMenuFocusEnabled:YES + focusedAction:InGameActionForFocus( + _inGameFocusGraph.current())]; +} + +- (BOOL)handleControllerActionsForTableController:(UITableViewController*)table_controller + actions: + (const xe::ui::apple::ControllerActionSet&)actions { + UITableView* table_view = table_controller.tableView; + if (!table_view) { + return NO; + } + + NSMutableArray* all_paths = [NSMutableArray array]; + NSInteger sections = [table_view numberOfSections]; + for (NSInteger section = 0; section < sections; ++section) { + NSInteger rows = [table_view numberOfRowsInSection:section]; + for (NSInteger row = 0; row < rows; ++row) { + [all_paths addObject:[NSIndexPath indexPathForRow:row inSection:section]]; + } + } + if (all_paths.count == 0) { + return NO; + } + + NSIndexPath* selected = table_view.indexPathForSelectedRow; + NSInteger selected_index = 0; + if (selected) { + NSUInteger found = [all_paths indexOfObject:selected]; + if (found != NSNotFound) { + selected_index = static_cast(found); + } + } else { + selected = all_paths.firstObject; + [table_view selectRowAtIndexPath:selected + animated:NO + scrollPosition:UITableViewScrollPositionMiddle]; + } + + BOOL handled = NO; + if (actions.navigate_up && selected_index > 0) { + selected_index--; + handled = YES; + } + if (actions.navigate_down && selected_index + 1 < static_cast(all_paths.count)) { + selected_index++; + handled = YES; + } + if (actions.page_prev && selected_index > 0) { + NSInteger step = std::max(1, table_view.indexPathsForVisibleRows.count - 1); + selected_index = std::max(0, selected_index - step); + handled = YES; + } + if (actions.page_next && selected_index + 1 < static_cast(all_paths.count)) { + NSInteger step = std::max(1, table_view.indexPathsForVisibleRows.count - 1); + selected_index = + std::min(static_cast(all_paths.count - 1), selected_index + step); + handled = YES; + } + if (actions.section_prev && selected.section > 0) { + for (NSInteger target_section = selected.section - 1; target_section >= 0; --target_section) { + NSInteger rows = [table_view numberOfRowsInSection:target_section]; + if (rows > 0) { + selected = [NSIndexPath indexPathForRow:0 inSection:target_section]; + handled = YES; + break; + } + } + } else if (actions.section_next && selected.section + 1 < sections) { + for (NSInteger target_section = selected.section + 1; target_section < sections; + ++target_section) { + NSInteger rows = [table_view numberOfRowsInSection:target_section]; + if (rows > 0) { + selected = [NSIndexPath indexPathForRow:0 inSection:target_section]; + handled = YES; + break; + } + } + } else { + selected = all_paths[selected_index]; + } + + if (handled && selected) { + [table_view selectRowAtIndexPath:selected + animated:YES + scrollPosition:UITableViewScrollPositionMiddle]; + } + + if (actions.accept && selected) { + UITableViewCell* cell = [table_view cellForRowAtIndexPath:selected]; + if ([cell.accessoryView isKindOfClass:[UISwitch class]]) { + UISwitch* toggle = (UISwitch*)cell.accessoryView; + [toggle setOn:!toggle.isOn animated:YES]; + [toggle sendActionsForControlEvents:UIControlEventValueChanged]; + } else { + id delegate = table_view.delegate; + if ([delegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]) { + [delegate tableView:table_view didSelectRowAtIndexPath:selected]; + } + } + handled = YES; + } + + if (actions.quick_action && + TriggerBarButtonItem(table_controller.navigationItem.rightBarButtonItem)) { + handled = YES; + } + + if (actions.back) { + UINavigationController* nav = table_controller.navigationController; + if (nav && nav.viewControllers.count > 1) { + [nav popViewControllerAnimated:YES]; + } else { + [table_controller dismissViewControllerAnimated:YES completion:nil]; + } + handled = YES; + } + + return handled; +} + +- (BOOL)handlePresentedControllerActions:(const xe::ui::apple::ControllerActionSet&)actions { + UIViewController* presented = [_host controllerNavigationPresentedController]; + if (!presented) { + return NO; + } + + if ([presented isKindOfClass:[UIAlertController class]]) { + if (actions.back) { + [presented dismissViewControllerAnimated:YES completion:nil]; + return YES; + } + return NO; + } + + if ([presented isKindOfClass:[UINavigationController class]]) { + UINavigationController* nav = (UINavigationController*)presented; + UIViewController* top = nav.topViewController; + if ([top isKindOfClass:[XeniaLogViewController class]] && + [(XeniaLogViewController*)top handleControllerActions:actions]) { + return YES; + } + if ([top isKindOfClass:[UITableViewController class]] && + [self handleControllerActionsForTableController:(UITableViewController*)top + actions:actions]) { + return YES; + } + if (actions.quick_action && TriggerBarButtonItem(top.navigationItem.rightBarButtonItem)) { + return YES; + } + if (actions.back) { + if (nav.viewControllers.count > 1) { + [nav popViewControllerAnimated:YES]; + } else { + [nav dismissViewControllerAnimated:YES completion:nil]; + } + return YES; + } + return NO; + } + + if (actions.back) { + [presented dismissViewControllerAnimated:YES completion:nil]; + return YES; + } + return NO; +} + +- (BOOL)handleLauncherControllerActions:(const xe::ui::apple::ControllerActionSet&)actions { + if (![_host controllerNavigationLauncherVisible]) { + return NO; + } + + [self rebuildLauncherFocusGraph]; + IOSFocusNodeId current_focus = _launcherFocusGraph.current(); + NSInteger game_count = [_host controllerNavigationGameCount]; + BOOL handled = NO; + BOOL focus_changed = NO; + + auto move_focus = [&](xe::ui::apple::NavigationDirection direction) { + IOSFocusNodeId previous = _launcherFocusGraph.current(); + IOSFocusNodeId next = _launcherFocusGraph.Move(direction); + if (next != previous) { + focus_changed = YES; + } + }; + + if (actions.section_prev) { + IOSFocusNodeId target = + current_focus == kLauncherFocusLibrary ? kLauncherFocusImport : kLauncherFocusSettings; + if (_launcherFocusGraph.SetCurrent(target)) { + focus_changed = YES; + } + handled = YES; + } + if (actions.section_next && game_count > 0) { + if (_launcherFocusGraph.SetCurrent(kLauncherFocusLibrary)) { + focus_changed = YES; + } + handled = YES; + } + + current_focus = _launcherFocusGraph.current(); + if (current_focus == kLauncherFocusLibrary && game_count > 0) { + NSInteger columns = std::max(1, [_host controllerNavigationLauncherColumnCount]); + NSInteger next_index = _focusedGameIndex < 0 ? 0 : _focusedGameIndex; + + if (actions.navigate_left) { + if (next_index % columns == 0) { + move_focus(xe::ui::apple::NavigationDirection::kLeft); + } else if (next_index > 0) { + next_index--; + } + handled = YES; + } + if (actions.navigate_right) { + if (next_index + 1 < game_count) { + next_index++; + } + handled = YES; + } + if (actions.navigate_up) { + if (next_index - columns >= 0) { + next_index -= columns; + } else { + move_focus(xe::ui::apple::NavigationDirection::kUp); + } + handled = YES; + } + if (actions.navigate_down) { + if (next_index + columns < game_count) { + next_index += columns; + } + handled = YES; + } + if (actions.page_prev) { + NSInteger page_step = std::max(1, [_host controllerNavigationLauncherPageStep]); + next_index = std::max(0, next_index - page_step); + handled = YES; + } + if (actions.page_next) { + NSInteger page_step = std::max(1, [_host controllerNavigationLauncherPageStep]); + next_index = std::min(game_count - 1, next_index + page_step); + handled = YES; + } + + if (_launcherFocusGraph.current() == kLauncherFocusLibrary && + next_index != _focusedGameIndex) { + [self setFocusedGameIndex:next_index scroll:YES]; + handled = YES; + } + } else { + if (actions.navigate_up) { + move_focus(xe::ui::apple::NavigationDirection::kUp); + handled = YES; + } + if (actions.navigate_down) { + move_focus(xe::ui::apple::NavigationDirection::kDown); + handled = YES; + } + if (actions.navigate_left) { + move_focus(xe::ui::apple::NavigationDirection::kLeft); + handled = YES; + } + if (actions.navigate_right) { + move_focus(xe::ui::apple::NavigationDirection::kRight); + handled = YES; + } + } + + if (actions.context) { + if (_launcherFocusGraph.current() == kLauncherFocusLibrary && _focusedGameIndex >= 0 && + _focusedGameIndex < game_count) { + [_host controllerNavigationManageGameAtIndex:_focusedGameIndex]; + } else { + [_host controllerNavigationOpenProfile]; + } + handled = YES; + } + if (actions.quick_action) { + [_host controllerNavigationImportGame]; + handled = YES; + } + if (actions.guide) { + [_host controllerNavigationOpenSettings]; + handled = YES; + } + + if (actions.accept) { + switch (_launcherFocusGraph.current()) { + case kLauncherFocusSettings: + [_host controllerNavigationOpenSettings]; + handled = YES; + break; + case kLauncherFocusProfile: + [_host controllerNavigationOpenProfile]; + handled = YES; + break; + case kLauncherFocusImport: + [_host controllerNavigationImportGame]; + handled = YES; + break; + case kLauncherFocusLibrary: + if (_focusedGameIndex >= 0 && _focusedGameIndex < game_count) { + [_host controllerNavigationLaunchGameAtIndex:_focusedGameIndex]; + handled = YES; + } + break; + default: + break; + } + } + + if (focus_changed || handled) { + [self applyLauncherFocusVisuals]; + } + return handled; +} + +- (BOOL)handleInGameControllerActions:(const xe::ui::apple::ControllerActionSet&)actions { + if ([_host controllerNavigationLauncherVisible] || ![_host controllerNavigationGameRunning]) { + return NO; + } + + if (actions.guide) { + if (![_host controllerNavigationInGameMenuVisible]) { + [_host controllerNavigationShowInGameMenu]; + } else { + [_host controllerNavigationHideInGameMenu]; + } + return YES; + } + + if (![_host controllerNavigationInGameMenuVisible]) { + return NO; + } + + if (actions.back) { + [_host controllerNavigationHideInGameMenu]; + return YES; + } + + [self rebuildInGameFocusGraph]; + if (_inGameFocusGraph.current() == xe::ui::apple::kInvalidFocusNodeId) { + _inGameFocusGraph.SetCurrent(kInGameFocusResume); + } + + BOOL handled = NO; + BOOL focus_changed = NO; + auto move_focus = [&](xe::ui::apple::NavigationDirection direction) { + IOSFocusNodeId previous = _inGameFocusGraph.current(); + IOSFocusNodeId next = _inGameFocusGraph.Move(direction); + if (next != previous) { + focus_changed = YES; + } + }; + + if (actions.navigate_up) { + move_focus(xe::ui::apple::NavigationDirection::kUp); + handled = YES; + } + if (actions.navigate_down) { + move_focus(xe::ui::apple::NavigationDirection::kDown); + handled = YES; + } + if (actions.navigate_left) { + move_focus(xe::ui::apple::NavigationDirection::kUp); + handled = YES; + } + if (actions.navigate_right) { + move_focus(xe::ui::apple::NavigationDirection::kDown); + handled = YES; + } + + if (actions.section_prev && _inGameFocusGraph.SetCurrent(kInGameFocusResume)) { + focus_changed = YES; + handled = YES; + } + if (actions.section_next && _inGameFocusGraph.SetCurrent(kInGameFocusExit)) { + focus_changed = YES; + handled = YES; + } + + if (actions.context) { + [_host controllerNavigationPerformInGameMenuAction:XeniaIOSInGameMenuActionSettings]; + handled = YES; + } + if (actions.quick_action) { + [_host controllerNavigationPerformInGameMenuAction:XeniaIOSInGameMenuActionLiveLog]; + handled = YES; + } + + if (actions.accept) { + [_host controllerNavigationPerformInGameMenuAction:InGameActionForFocus( + _inGameFocusGraph.current())]; + handled = YES; + } + + if (focus_changed || handled) { + [self applyInGameMenuFocusVisuals]; + } + return handled; +} + +- (void)poll:(NSTimer* __unused)timer { + const bool navigation_enabled = [_host controllerNavigationHasConnectedController]; + if (!navigation_enabled) { + if (_navigationWasEnabled) { + _navigationWasEnabled = NO; + _mapper.Reset(); + _launcherFocusGraph.Clear(); + _inGameFocusGraph.Clear(); + [self applyLauncherFocusVisuals]; + [self applyInGameMenuFocusVisuals]; + } + return; + } + + if (!_navigationWasEnabled) { + _navigationWasEnabled = YES; + [self refreshLauncherFocus]; + } + + xe::hid::X_INPUT_STATE state = {}; + bool has_state = [_host controllerNavigationReadEmulatorControllerState:&state]; + if (!has_state) { + has_state = [self readNativeControllerState:&state]; + } + if (!has_state) { + _mapper.Reset(); + return; + } + + xe::ui::apple::ControllerActionSet actions = _mapper.Update(state, GetNowMs()); + if (!actions.Any()) { + return; + } + + if ([self handlePresentedControllerActions:actions]) { + return; + } + if ([self handleLauncherControllerActions:actions]) { + return; + } + [self handleInGameControllerActions:actions]; +} + +- (BOOL)readNativeControllerState:(xe::hid::X_INPUT_STATE*)out_state { + if (!out_state) { + return NO; + } + + NSArray* controllers = [GCController controllers]; + for (GCController* controller in controllers) { + GCExtendedGamepad* gamepad = controller.extendedGamepad; + if (!gamepad) { + continue; + } + + uint16_t buttons = 0; + auto set_button = [&buttons](BOOL pressed, uint16_t mask) { + if (pressed) { + buttons |= mask; + } + }; + + set_button(gamepad.dpad.up.pressed, xe::hid::X_INPUT_GAMEPAD_DPAD_UP); + set_button(gamepad.dpad.down.pressed, xe::hid::X_INPUT_GAMEPAD_DPAD_DOWN); + set_button(gamepad.dpad.left.pressed, xe::hid::X_INPUT_GAMEPAD_DPAD_LEFT); + set_button(gamepad.dpad.right.pressed, xe::hid::X_INPUT_GAMEPAD_DPAD_RIGHT); + set_button(gamepad.buttonA.pressed, xe::hid::X_INPUT_GAMEPAD_A); + set_button(gamepad.buttonB.pressed, xe::hid::X_INPUT_GAMEPAD_B); + set_button(gamepad.buttonX.pressed, xe::hid::X_INPUT_GAMEPAD_X); + set_button(gamepad.buttonY.pressed, xe::hid::X_INPUT_GAMEPAD_Y); + set_button(gamepad.leftShoulder.pressed, xe::hid::X_INPUT_GAMEPAD_LEFT_SHOULDER); + set_button(gamepad.rightShoulder.pressed, xe::hid::X_INPUT_GAMEPAD_RIGHT_SHOULDER); + + if (@available(iOS 13.0, tvOS 13.0, macCatalyst 13.0, *)) { + set_button(gamepad.buttonMenu.pressed, xe::hid::X_INPUT_GAMEPAD_START); + } + if (@available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *)) { + set_button(gamepad.buttonOptions.pressed, xe::hid::X_INPUT_GAMEPAD_BACK); + } + if (@available(iOS 12.1, tvOS 12.1, macCatalyst 13.1, *)) { + set_button(gamepad.leftThumbstickButton.pressed, xe::hid::X_INPUT_GAMEPAD_LEFT_THUMB); + set_button(gamepad.rightThumbstickButton.pressed, xe::hid::X_INPUT_GAMEPAD_RIGHT_THUMB); + } + + out_state->packet_number = ++_nativeControllerPacketNumber; + out_state->gamepad.buttons = buttons; + out_state->gamepad.left_trigger = ToTriggerAxis(gamepad.leftTrigger.value); + out_state->gamepad.right_trigger = ToTriggerAxis(gamepad.rightTrigger.value); + out_state->gamepad.thumb_lx = ToThumbAxis(gamepad.leftThumbstick.xAxis.value); + out_state->gamepad.thumb_ly = ToThumbAxis(gamepad.leftThumbstick.yAxis.value); + out_state->gamepad.thumb_rx = ToThumbAxis(gamepad.rightThumbstick.xAxis.value); + out_state->gamepad.thumb_ry = ToThumbAxis(gamepad.rightThumbstick.yAxis.value); + return YES; + } + return NO; +} + +@end diff --git a/src/xenia/ui/ios/app/ios_display_settings_view_controller.h b/src/xenia/ui/ios/app/ios_display_settings_view_controller.h new file mode 100644 index 000000000..d95fd1707 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_display_settings_view_controller.h @@ -0,0 +1,38 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_DISPLAY_SETTINGS_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_APP_IOS_DISPLAY_SETTINGS_VIEW_CONTROLLER_H_ + +#import + +#import "xenia/ui/ios/app/ios_window_layout.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Host accessors backing the Display page. XeniaViewController already +// implements all of these (it owns the window scaling/letterbox/refresh state), +// so it conforms with no extra glue. +@protocol XeniaDisplaySettingsHost +- (XeniaIOSWindowScalingMode)currentWindowScalingMode; +- (void)setCurrentWindowScalingMode:(XeniaIOSWindowScalingMode)mode; +- (BOOL)isPresentLetterboxEnabled; +- (void)setPresentLetterboxEnabled:(BOOL)enabled; +- (BOOL)isGuestDisplayUncapped; +- (void)setGuestDisplayUncapped:(BOOL)uncapped; +@end + +// Small pushable Display page used by the pause dashboard in place of the older +// context UIMenu: a plain-language screen-mode picker (Fit / Fill / Stretch) +// plus the letterbox and uncapped-refresh toggles. Changes apply live through +// the host. +@interface XeniaDisplaySettingsViewController : XESheetTableViewController +- (instancetype)initWithHost:(id)host; +@end + +#endif // XENIA_UI_IOS_APP_IOS_DISPLAY_SETTINGS_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/app/ios_display_settings_view_controller.mm b/src/xenia/ui/ios/app/ios_display_settings_view_controller.mm new file mode 100644 index 000000000..cc2a80e9e --- /dev/null +++ b/src/xenia/ui/ios/app/ios_display_settings_view_controller.mm @@ -0,0 +1,137 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_display_settings_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { +// Plain-language relabel of the three scaling modes (see proposal §5). The third +// column is the underlying XeniaIOSWindowScalingMode. +struct ScreenModeOption { + XeniaIOSWindowScalingMode mode; + __unsafe_unretained NSString* title; + __unsafe_unretained NSString* subtitle; +}; +const ScreenModeOption kScreenModes[] = { + {XeniaIOSWindowScalingModeFit, @"Fit", @"Preserve aspect ratio"}, + {XeniaIOSWindowScalingModeZoom, @"Fill", @"Crop edges to fill the screen"}, + {XeniaIOSWindowScalingModeStretch, @"Stretch", @"Ignore aspect ratio"}, +}; +constexpr NSInteger kSectionScreenMode = 0; +constexpr NSInteger kSectionOptions = 1; +} // namespace + +@implementation XeniaDisplaySettingsViewController { + id host_; // assign +} + +- (instancetype)initWithHost:(id)host { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + host_ = host; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Display"; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 56.0; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView { + return 2; +} + +- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section { + if (section == kSectionScreenMode) { + return (NSInteger)(sizeof(kScreenModes) / sizeof(kScreenModes[0])); + } + return 2; // Letterbox, Uncapped. +} + +- (NSString*)tableView:(UITableView*)tableView titleForHeaderInSection:(NSInteger)section { + return section == kSectionScreenMode ? @"Screen Mode" : @"Options"; +} + +- (UISwitch*)switchWithOn:(BOOL)on action:(SEL)action { + UISwitch* control = [[[UISwitch alloc] init] autorelease]; + control.on = on; + control.onTintColor = [XeniaTheme accent]; + [control addTarget:self action:action forControlEvents:UIControlEventValueChanged]; + return control; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.section == kSectionScreenMode) { + static NSString* const kModeCell = @"XeniaDisplayModeCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kModeCell]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kModeCell] autorelease]; + } + const ScreenModeOption& option = kScreenModes[(size_t)indexPath.row]; + cell.textLabel.text = option.title; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = option.subtitle; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + const BOOL selected = host_ && [host_ currentWindowScalingMode] == option.mode; + cell.accessoryType = + selected ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + cell.tintColor = [XeniaTheme accent]; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = option.title; + cell.accessibilityValue = selected ? @"Selected" : nil; + cell.accessibilityTraits = UIAccessibilityTraitButton; + return cell; + } + + static NSString* const kToggleCell = @"XeniaDisplayToggleCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kToggleCell]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:kToggleCell] autorelease]; + } + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + if (indexPath.row == 0) { + cell.textLabel.text = @"Letterbox"; + cell.accessoryView = [self switchWithOn:(host_ && [host_ isPresentLetterboxEnabled]) + action:@selector(letterboxChanged:)]; + } else { + cell.textLabel.text = @"Uncapped Emulated Display"; + cell.accessoryView = [self switchWithOn:(host_ && [host_ isGuestDisplayUncapped]) + action:@selector(uncappedChanged:)]; + } + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section != kSectionScreenMode) { + return; + } + [host_ setCurrentWindowScalingMode:kScreenModes[(size_t)indexPath.row].mode]; + [tableView reloadSections:[NSIndexSet indexSetWithIndex:kSectionScreenMode] + withRowAnimation:UITableViewRowAnimationAutomatic]; +} + +- (void)letterboxChanged:(UISwitch*)sender { + [host_ setPresentLetterboxEnabled:sender.on]; +} + +- (void)uncappedChanged:(UISwitch*)sender { + [host_ setGuestDisplayUncapped:sender.on]; +} + +@end diff --git a/src/xenia/ui/ios/app/ios_in_game_menu_overlay.h b/src/xenia/ui/ios/app/ios_in_game_menu_overlay.h new file mode 100644 index 000000000..7d547ff85 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_in_game_menu_overlay.h @@ -0,0 +1,53 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_IN_GAME_MENU_OVERLAY_H_ +#define XENIA_UI_IOS_APP_IOS_IN_GAME_MENU_OVERLAY_H_ + +#ifdef __OBJC__ + +#import + +typedef NS_ENUM(NSInteger, XeniaIOSInGameMenuAction) { + XeniaIOSInGameMenuActionNone = -1, + XeniaIOSInGameMenuActionResume = 0, + XeniaIOSInGameMenuActionEditControls, + XeniaIOSInGameMenuActionAchievements, + XeniaIOSInGameMenuActionDisplay = 4, + XeniaIOSInGameMenuActionSettings, + XeniaIOSInGameMenuActionLiveLog, + XeniaIOSInGameMenuActionExit, + XeniaIOSInGameMenuActionGraphics = 8, +}; + +@interface XeniaIOSInGameMenuOverlay : UIView + +@property(nonatomic, copy) void (^resumeHandler)(void); +@property(nonatomic, copy) void (^editControlsHandler)(void); +@property(nonatomic, copy) void (^achievementsHandler)(void); +@property(nonatomic, copy) void (^settingsHandler)(void); +@property(nonatomic, copy) void (^liveLogHandler)(void); +@property(nonatomic, copy) void (^exitHandler)(void); +@property(nonatomic, copy) void (^graphicsHandler)(void); +@property(nonatomic, strong) UIMenu* displayMenu; + +- (BOOL)isOverlayVisible; +- (BOOL)isActionEnabled:(XeniaIOSInGameMenuAction)action; +- (void)performAction:(XeniaIOSInGameMenuAction)action; +- (void)setControllerNavigationEnabled:(BOOL)enabled + focusedAction:(XeniaIOSInGameMenuAction)focusedAction; +- (void)setOverlayVisible:(BOOL)visible + animated:(BOOL)animated + completion:(void (^)(BOOL finished))completion; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_APP_IOS_IN_GAME_MENU_OVERLAY_H_ diff --git a/src/xenia/ui/ios/app/ios_in_game_menu_overlay.mm b/src/xenia/ui/ios/app/ios_in_game_menu_overlay.mm new file mode 100644 index 000000000..246a552f9 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_in_game_menu_overlay.mm @@ -0,0 +1,467 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_in_game_menu_overlay.h" + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +@implementation XeniaIOSInGameMenuOverlay { + UIView* _panel; + UIButton* _resumeButton; + UIButton* _editControlsButton; + UIButton* _achievementsButton; + UIButton* _displayButton; + UIButton* _settingsButton; + UIButton* _graphicsButton; + UIButton* _liveLogButton; + UIButton* _exitButton; + UIMenu* _displayMenu; + NSLayoutConstraint* _panelWidthConstraint; + BOOL _controllerNavigationEnabled; + XeniaIOSInGameMenuAction _focusedAction; + void (^_graphicsHandler)(void); +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (!(self = [super initWithFrame:frame])) { + return nil; + } + + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.backgroundColor = [XeniaTheme overlayLight]; + self.hidden = YES; + self.userInteractionEnabled = NO; + _focusedAction = XeniaIOSInGameMenuActionNone; + _controllerNavigationEnabled = NO; + + _panel = [[UIView alloc] init]; + _panel.translatesAutoresizingMaskIntoConstraints = NO; + xe_apply_floating_window_chrome(_panel); + [self addSubview:_panel]; + + UILabel* title = [[UILabel alloc] init]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.text = @"In-Game Menu"; + title.textColor = [XeniaTheme textPrimary]; + xe_apply_label_font(title, UIFontTextStyleTitle2, 22.0, UIFontWeightSemibold); + title.textAlignment = NSTextAlignmentCenter; + title.accessibilityTraits = UIAccessibilityTraitHeader; + [_panel addSubview:title]; + + UILabel* subtitle = [[UILabel alloc] init]; + subtitle.translatesAutoresizingMaskIntoConstraints = NO; + subtitle.text = @"Tap anywhere to close"; + subtitle.textColor = [XeniaTheme textMuted]; + xe_apply_label_font(subtitle, UIFontTextStyleSubheadline, 15.0, UIFontWeightRegular); + subtitle.textAlignment = NSTextAlignmentCenter; + [_panel addSubview:subtitle]; + + _resumeButton = [self newButtonWithTitle:@"Resume" + imageName:nil + backgroundColor:[XeniaTheme accent] + foregroundColor:[XeniaTheme accentFg] + action:@selector(resumePressed:)]; + [_panel addSubview:_resumeButton]; + + _editControlsButton = [self newButtonWithTitle:@"Edit Controls" + imageName:@"hand.tap" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(editControlsPressed:)]; + [_panel addSubview:_editControlsButton]; + + _achievementsButton = [self newButtonWithTitle:@"Achievements" + imageName:@"trophy" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(achievementsPressed:)]; + [_panel addSubview:_achievementsButton]; + + _displayButton = [self newButtonWithTitle:@"Display" + imageName:@"rectangle.expand.vertical" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:nil]; + _displayButton.showsMenuAsPrimaryAction = YES; + [_panel addSubview:_displayButton]; + + _settingsButton = [self newButtonWithTitle:@"Settings" + imageName:@"slider.horizontal.3" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(settingsPressed:)]; + [_panel addSubview:_settingsButton]; + + _graphicsButton = [self newButtonWithTitle:@"Graphics" + imageName:@"gearshape.2.fill" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(graphicsPressed:)]; + [_panel addSubview:_graphicsButton]; + + _liveLogButton = [self newButtonWithTitle:@"Live Log" + imageName:@"doc.text" + backgroundColor:[XeniaTheme bgSurface2] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(liveLogPressed:)]; + [_panel addSubview:_liveLogButton]; + + _exitButton = [self newButtonWithTitle:@"Exit To Library" + imageName:@"rectangle.portrait.and.arrow.right" + backgroundColor:[[XeniaTheme statusError] colorWithAlphaComponent:0.25] + foregroundColor:[XeniaTheme textPrimary] + action:@selector(exitPressed:)]; + [_panel addSubview:_exitButton]; + + [NSLayoutConstraint activateConstraints:@[ + [_panel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor], + [_panel.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [_panel.leadingAnchor constraintGreaterThanOrEqualToAnchor:self.safeAreaLayoutGuide.leadingAnchor + constant:24], + [_panel.trailingAnchor constraintLessThanOrEqualToAnchor:self.safeAreaLayoutGuide.trailingAnchor + constant:-24], + + [title.topAnchor constraintEqualToAnchor:_panel.topAnchor constant:18], + [title.leadingAnchor constraintEqualToAnchor:_panel.leadingAnchor constant:20], + [title.trailingAnchor constraintEqualToAnchor:_panel.trailingAnchor constant:-20], + + [subtitle.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:4], + [subtitle.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], + [subtitle.trailingAnchor constraintEqualToAnchor:title.trailingAnchor], + ]]; + + _panelWidthConstraint = [_panel.widthAnchor constraintLessThanOrEqualToConstant:420]; + [NSLayoutConstraint activateConstraints:@[ + _panelWidthConstraint, + + // Resume — full width. + [_resumeButton.topAnchor constraintEqualToAnchor:subtitle.bottomAnchor constant:16], + [_resumeButton.leadingAnchor constraintEqualToAnchor:_panel.leadingAnchor constant:14], + [_resumeButton.trailingAnchor constraintEqualToAnchor:_panel.trailingAnchor constant:-14], + + // Row 1: Edit Controls | Graphics + [_editControlsButton.topAnchor constraintEqualToAnchor:_resumeButton.bottomAnchor constant:10], + [_editControlsButton.leadingAnchor constraintEqualToAnchor:_resumeButton.leadingAnchor], + [_graphicsButton.topAnchor constraintEqualToAnchor:_editControlsButton.topAnchor], + [_graphicsButton.leadingAnchor constraintEqualToAnchor:_editControlsButton.trailingAnchor + constant:10], + [_graphicsButton.trailingAnchor constraintEqualToAnchor:_resumeButton.trailingAnchor], + [_graphicsButton.widthAnchor constraintEqualToAnchor:_editControlsButton.widthAnchor], + + // Row 2: Display | Settings + [_displayButton.topAnchor constraintEqualToAnchor:_editControlsButton.bottomAnchor constant:10], + [_displayButton.leadingAnchor constraintEqualToAnchor:_resumeButton.leadingAnchor], + [_settingsButton.topAnchor constraintEqualToAnchor:_displayButton.topAnchor], + [_settingsButton.leadingAnchor constraintEqualToAnchor:_displayButton.trailingAnchor + constant:10], + [_settingsButton.trailingAnchor constraintEqualToAnchor:_resumeButton.trailingAnchor], + [_settingsButton.widthAnchor constraintEqualToAnchor:_displayButton.widthAnchor], + + // Row 3: Achievements | Exit + [_achievementsButton.topAnchor constraintEqualToAnchor:_displayButton.bottomAnchor + constant:10], + [_achievementsButton.leadingAnchor constraintEqualToAnchor:_resumeButton.leadingAnchor], + [_exitButton.topAnchor constraintEqualToAnchor:_achievementsButton.topAnchor], + [_exitButton.leadingAnchor constraintEqualToAnchor:_achievementsButton.trailingAnchor + constant:10], + [_exitButton.trailingAnchor constraintEqualToAnchor:_resumeButton.trailingAnchor], + [_exitButton.widthAnchor constraintEqualToAnchor:_achievementsButton.widthAnchor], + + // Footer: Live Log + [_liveLogButton.topAnchor constraintEqualToAnchor:_achievementsButton.bottomAnchor constant:10], + [_liveLogButton.centerXAnchor constraintEqualToAnchor:_panel.centerXAnchor], + [_liveLogButton.bottomAnchor constraintEqualToAnchor:_panel.bottomAnchor constant:-14], + ]]; + [title release]; + [subtitle release]; + return self; +} + +- (void)dealloc { + [_resumeHandler release]; + [_editControlsHandler release]; + [_achievementsHandler release]; + [_settingsHandler release]; + [_liveLogHandler release]; + [_exitHandler release]; + [_graphicsHandler release]; + [_displayMenu release]; + [_panelWidthConstraint release]; + [_panel release]; + [_resumeButton release]; + [_editControlsButton release]; + [_achievementsButton release]; + [_displayButton release]; + [_settingsButton release]; + [_graphicsButton release]; + [_liveLogButton release]; + [_exitButton release]; + [super dealloc]; +} + +- (UIButton*)newButtonWithTitle:(NSString*)title + imageName:(NSString*)imageName + backgroundColor:(UIColor*)backgroundColor + foregroundColor:(UIColor*)foregroundColor + action:(SEL)action { + UIButtonConfiguration* config = [UIButtonConfiguration tintedButtonConfiguration]; + config.title = title; + if (imageName.length) { + config.image = [UIImage systemImageNamed:imageName]; + config.imagePadding = 6; + } + config.baseForegroundColor = foregroundColor; + config.baseBackgroundColor = backgroundColor; + config.cornerStyle = UIButtonConfigurationCornerStyleLarge; + config.contentInsets = NSDirectionalEdgeInsetsMake(10, 16, 10, 16); + if ([title isEqualToString:@"Resume"]) { + config = [UIButtonConfiguration filledButtonConfiguration]; + config.title = title; + config.baseBackgroundColor = backgroundColor; + config.baseForegroundColor = foregroundColor; + config.cornerStyle = UIButtonConfigurationCornerStyleLarge; + config.contentInsets = NSDirectionalEdgeInsetsMake(12, 18, 12, 18); + } + UIButton* button = [[UIButton buttonWithConfiguration:config primaryAction:nil] retain]; + button.translatesAutoresizingMaskIntoConstraints = NO; + xe_apply_button_title_font(button, UIFontTextStyleBody, 16.0, UIFontWeightSemibold); + button.accessibilityLabel = title; + if ([title isEqualToString:@"Resume"]) { + button.accessibilityHint = @"Returns to the game."; + } else if ([title isEqualToString:@"Edit Controls"]) { + button.accessibilityHint = @"Opens the touch control editor."; + } else if ([title isEqualToString:@"Achievements"]) { + button.accessibilityHint = @"Shows achievements for the current game."; + } else if ([title isEqualToString:@"Display"]) { + button.accessibilityHint = @"Opens display scaling and position options."; + } else if ([title isEqualToString:@"Graphics"]) { + button.accessibilityHint = @"Opens graphics compatibility settings."; + } else if ([title isEqualToString:@"Settings"]) { + button.accessibilityHint = @"Opens XeniOS settings."; + } else if ([title isEqualToString:@"Live Log"]) { + button.accessibilityHint = @"Opens the live emulator log."; + } else if ([title isEqualToString:@"Exit To Library"]) { + button.accessibilityHint = @"Stops the game and returns to the library."; + } + if (action) { + [button addTarget:self action:action forControlEvents:UIControlEventTouchUpInside]; + } + return button; +} + +- (void)setDisplayMenu:(UIMenu*)displayMenu { + if (_displayMenu == displayMenu) { + return; + } + [_displayMenu release]; + _displayMenu = [displayMenu retain]; + _displayButton.menu = _displayMenu; +} + +- (UIMenu*)displayMenu { + return _displayMenu; +} + +- (void)setGraphicsHandler:(void (^)(void))handler { + if (_graphicsHandler == handler) return; + [_graphicsHandler release]; + _graphicsHandler = [handler copy]; +} + +- (void (^)(void))graphicsHandler { + return _graphicsHandler; +} + +- (BOOL)isOverlayVisible { + return !self.hidden; +} + +- (UIButton*)buttonForAction:(XeniaIOSInGameMenuAction)action { + switch (action) { + case XeniaIOSInGameMenuActionResume: + return _resumeButton; + case XeniaIOSInGameMenuActionEditControls: + return _editControlsButton; + case XeniaIOSInGameMenuActionAchievements: + return _achievementsButton; + case XeniaIOSInGameMenuActionDisplay: + return _displayButton; + case XeniaIOSInGameMenuActionSettings: + return _settingsButton; + case XeniaIOSInGameMenuActionLiveLog: + return _liveLogButton; + case XeniaIOSInGameMenuActionExit: + return _exitButton; + case XeniaIOSInGameMenuActionGraphics: + return _graphicsButton; + case XeniaIOSInGameMenuActionNone: + default: + return nil; + } +} + +- (BOOL)isActionEnabled:(XeniaIOSInGameMenuAction)action { + UIButton* button = [self buttonForAction:action]; + return button && button.enabled && !button.hidden; +} + +- (void)performAction:(XeniaIOSInGameMenuAction)action { + UIButton* button = [self buttonForAction:action]; + if (!button || !button.enabled || button.hidden) { + return; + } + if (action == XeniaIOSInGameMenuActionDisplay) { + if (@available(iOS 17.4, *)) { + [button performPrimaryAction]; + return; + } + } + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; +} + +- (void)setButton:(UIButton*)button controllerFocused:(BOOL)focused { + if (!button) { + return; + } + button.layer.cornerRadius = XeniaRadiusMd; + button.layer.borderWidth = focused ? 1.5 : 0.0; + button.layer.borderColor = focused ? [XeniaTheme accent].CGColor : [UIColor clearColor].CGColor; + button.layer.shadowColor = [XeniaTheme accent].CGColor; + button.layer.shadowOpacity = focused ? 0.35f : 0.0f; + button.layer.shadowRadius = focused ? 6.0f : 0.0f; + button.layer.shadowOffset = CGSizeZero; +} + +- (void)setControllerNavigationEnabled:(BOOL)enabled + focusedAction:(XeniaIOSInGameMenuAction)focusedAction { + _controllerNavigationEnabled = enabled; + _focusedAction = enabled ? focusedAction : XeniaIOSInGameMenuActionNone; + + [self setButton:_resumeButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionResume]; + [self setButton:_editControlsButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionEditControls]; + [self setButton:_achievementsButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionAchievements]; + [self setButton:_displayButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionDisplay]; + [self setButton:_settingsButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionSettings]; + [self setButton:_liveLogButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionLiveLog]; + [self setButton:_graphicsButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionGraphics]; + [self setButton:_exitButton + controllerFocused:_controllerNavigationEnabled && + _focusedAction == XeniaIOSInGameMenuActionExit]; +} + +- (void)setOverlayVisible:(BOOL)visible + animated:(BOOL)animated + completion:(void (^)(BOOL finished))completion { + self.userInteractionEnabled = visible; + if (visible == !self.hidden) { + if (completion) { + completion(YES); + } + return; + } + + if (!animated) { + self.hidden = !visible; + self.alpha = visible ? 1.0 : 0.0; + if (!visible) { + self.alpha = 1.0; + } + if (completion) { + completion(YES); + } + return; + } + + if (visible) { + self.alpha = 0.0; + self.hidden = NO; + [UIView animateWithDuration:0.18 + animations:^{ + self.alpha = 1.0; + } + completion:completion]; + } else { + [UIView animateWithDuration:0.15 + animations:^{ + self.alpha = 0.0; + } + completion:^(BOOL finished) { + self.hidden = YES; + self.alpha = 1.0; + if (completion) { + completion(finished); + } + }]; + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + const BOOL isLandscape = self.bounds.size.width > self.bounds.size.height; + _panelWidthConstraint.constant = isLandscape ? 540.0 : 420.0; +} + +- (void)resumePressed:(UIButton*)__unused sender { + if (_resumeHandler) { + _resumeHandler(); + } +} + +- (void)editControlsPressed:(UIButton*)__unused sender { + if (_editControlsHandler) { + _editControlsHandler(); + } +} + +- (void)achievementsPressed:(UIButton*)__unused sender { + if (_achievementsHandler) { + _achievementsHandler(); + } +} + +- (void)settingsPressed:(UIButton*)__unused sender { + if (_settingsHandler) { + _settingsHandler(); + } +} + +- (void)liveLogPressed:(UIButton*)__unused sender { + if (_liveLogHandler) { + _liveLogHandler(); + } +} + +- (void)graphicsPressed:(UIButton*)__unused sender { + if (_graphicsHandler) { + _graphicsHandler(); + } +} + +- (void)exitPressed:(UIButton*)__unused sender { + if (_exitHandler) { + _exitHandler(); + } +} + +@end diff --git a/src/xenia/ui/ios/app/ios_landscape_navigation_controller.h b/src/xenia/ui/ios/app/ios_landscape_navigation_controller.h new file mode 100644 index 000000000..529027aa2 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_landscape_navigation_controller.h @@ -0,0 +1,22 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LANDSCAPE_NAVIGATION_CONTROLLER_H_ +#define XENIA_UI_IOS_LANDSCAPE_NAVIGATION_CONTROLLER_H_ + +#import + +// UINavigationController used as the host for sheets that can rotate freely +// alongside the emulator window — landscape, portrait or back. Exists solely +// to override the orientation gating that UIKit otherwise inherits from the +// presenting controller. +@interface XeniaLandscapeNavigationController : UINavigationController +@end + +#endif // XENIA_UI_IOS_LANDSCAPE_NAVIGATION_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/app/ios_landscape_navigation_controller.mm b/src/xenia/ui/ios/app/ios_landscape_navigation_controller.mm new file mode 100644 index 000000000..dc43dbac6 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_landscape_navigation_controller.mm @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_landscape_navigation_controller.h" + +#import "xenia/ui/ios/shared/ios_system_utils.h" + +@implementation XeniaLandscapeNavigationController + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskAll; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return xe_current_interface_orientation(self.view); +} + +- (BOOL)shouldAutorotate { + return YES; +} + +@end diff --git a/src/xenia/ui/ios/app/ios_main_view_controller.h b/src/xenia/ui/ios/app/ios_main_view_controller.h new file mode 100644 index 000000000..edd5c61b1 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_main_view_controller.h @@ -0,0 +1,67 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_MAIN_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_MAIN_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/achievement_notification_payload.h" + +@class XeniaMetalView; + +namespace xe { +namespace hid { +namespace touch { +class IOSTouchRuntimeModel; +} // namespace touch +} // namespace hid +namespace ui { +class IOSWindowedAppContext; +} // namespace ui +} // namespace xe + +// Root iOS UI: hosts the XeniaMetalView (the running emulator surface) and +// the launcher overlay (game library + import + settings + profile + JIT +// status). Owns the in-game menu overlay, controller-driven focus +// navigation, JIT polling, automatic StikDebug handoff and the external +// xenios:// launch flow. +@interface XeniaViewController : UIViewController + +// App-delegate bridge surface. +@property(nonatomic, strong) XeniaMetalView* metalView; +@property(nonatomic, strong) UILabel* statusLabel; +@property(nonatomic, assign) xe::ui::IOSWindowedAppContext* appContext; + +- (void)refreshSignedInProfileUI; +- (void)refreshImportedGames; +- (void)showLauncherOverlay; +- (void)presentSystemSigninPromptForUserIndex:(uint32_t)user_index + usersNeeded:(uint32_t)users_needed + completion:(void (^)(BOOL success))completion; +- (void)presentAchievementsForUserIndex:(uint32_t)user_index + titleID:(uint32_t)title_id + completion:(void (^)(BOOL success))completion; +- (void)presentAchievementNotification:(const xe::ui::AchievementNotificationPayload&)payload; +- (void)presentSystemKeyboardPromptWithTitle:(NSString*)title + description:(NSString*)description + defaultText:(NSString*)default_text + completion:(void (^)(BOOL cancelled, NSString* text))completion; +- (BOOL)handleExternalLaunchURL:(NSURL*)url; +- (void)evaluateAutomaticStikDebugJITHandoffIfNeeded; + +// Returns the IOSTouchRuntimeModel owned by this controller. Used by the app +// delegate to publish the runtime model into the iOS app context so the touch +// HID driver can pull state. +- (xe::hid::touch::IOSTouchRuntimeModel*)touchRuntimeModel; +@end + +#endif // XENIA_UI_IOS_MAIN_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/app/ios_main_view_controller.mm b/src/xenia/ui/ios/app/ios_main_view_controller.mm new file mode 100644 index 000000000..7310c3c2b --- /dev/null +++ b/src/xenia/ui/ios/app/ios_main_view_controller.mm @@ -0,0 +1,3013 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_main_view_controller.h" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/filesystem.h" +#include "xenia/base/logging.h" +#include "xenia/base/string.h" +#include "xenia/config.h" +#include "xenia/hid/input.h" +#include "xenia/vfs/stfs_metadata.h" +#include "xenia/xbox.h" + +#import "xenia/hid/touch/touch_layout_ios.h" +#import "xenia/ui/ios/app/ios_controller_navigation_coordinator.h" +#import "xenia/ui/ios/app/ios_in_game_menu_overlay.h" +#import "xenia/ui/ios/app/ios_landscape_navigation_controller.h" +#import "xenia/ui/ios/app/ios_window_layout.h" +#import "xenia/ui/ios/app/ios_window_position_overlay.h" +#import "xenia/ui/ios/app/windowed_app_context_ios.h" +#import "xenia/ui/ios/game/ios_achievement_notification_presenter.h" +#import "xenia/ui/ios/game/ios_achievements_view_controller.h" +#import "xenia/ui/ios/game/ios_metal_view.h" +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/launcher/ios_compat_report_view_controller.h" +#import "xenia/ui/ios/launcher/ios_content_management.h" +#import "xenia/ui/ios/launcher/ios_document_import_coordinator.h" +#import "xenia/ui/ios/launcher/ios_external_url.h" +#import "xenia/ui/ios/launcher/ios_game_art.h" +#import "xenia/ui/ios/launcher/ios_game_actions_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_content_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_disc_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_library.h" +#import "xenia/ui/ios/launcher/ios_game_library_store.h" +#import "xenia/ui/ios/launcher/ios_game_patches_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_picker_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_tile_cell.h" +#import "xenia/ui/ios/launcher/ios_launcher_overlay_view.h" +#import "xenia/ui/ios/settings/ios_choice_list_view_controller.h" +#import "xenia/ui/ios/settings/ios_config_builder.h" +#import "xenia/ui/ios/settings/ios_config_models.h" +#import "xenia/ui/ios/settings/ios_config_view_controller.h" +#import "xenia/ui/ios/settings/ios_quick_settings_view_controller.h" +#import "xenia/ui/ios/settings/ios_log_view_controller.h" +#import "xenia/ui/ios/settings/ios_profile_view_controller.h" +#import "xenia/ui/ios/settings/ios_settings_hub_view_controller.h" +#import "xenia/ui/ios/shared/ios_status_toast.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" +#import "xenia/ui/ios/touch/touch_controls_overlay_ios.h" +#import "xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h" +#import "xenia/ui/ios/touch/touch_layout_library_ios.h" +#import "xenia/ui/ios/touch/touch_layout_store_ios.h" +#import "xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h" + +DECLARE_bool(ios_touch_overlay); +DECLARE_bool(present_letterbox); + +using xe::ui::ApplyIOSCompatibilityDataToDiscoveredGames; +using xe::ui::BuildDiscoveredGameFromPath; +using xe::ui::ExtractLaunchPathFromExternalURL; +using xe::ui::ExtractLaunchTitleIDFromExternalURL; +using xe::ui::FormatTitleID; +using xe::ui::ImportGameIntoIOSLibrary; +using xe::ui::IOSDiscoveredGame; +using xe::ui::IOSImportedGamesDirectory; +using xe::ui::IsExternalGameInfoRequestURL; +using xe::ui::IsLikelyGodContainerFile; +using xe::ui::NormalizeURLToken; +using xe::ui::ScanIOSGameLibrary; +using xe::ui::SortDiscoveredGames; +using xe::ui::xe_game_info_callback_provider; +using xe::ui::xe_launch_url_for_title_id; +using xe::ui::xe_stikdebug_enable_jit_url_for_bundle_identifier; + +namespace { + +constexpr NSTimeInterval kXeniaAutoStikDebugCooldownSeconds = 10.0; + +NSTimeInterval GetUnixTimeSeconds() { return [[NSDate date] timeIntervalSince1970]; } + +std::string TrimAscii(std::string value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + ++start; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +static BOOL xe_game_system_supports_compatibility(xe::ui::IOSGameSystem system) { + return system == xe::ui::IOSGameSystem::kXbox360; +} + +static BOOL xe_game_system_supports_manage_content(xe::ui::IOSGameSystem system) { + return system == xe::ui::IOSGameSystem::kXbox360; +} + +static BOOL xe_game_system_supports_remote_art(xe::ui::IOSGameSystem system) { + return system == xe::ui::IOSGameSystem::kXbox360; +} + +} // namespace + +// Private touch-overlay/library methods and internal UI state. Kept out of the +// public header so the app delegate sees only the bridge surface it needs. +@interface XeniaViewController () +@property(nonatomic, strong) XeniaIOSLauncherOverlayView* launcherOverlayView; +@property(nonatomic, strong) UILabel* signedInProfileLabel; +@property(nonatomic, strong) XeniaIOSInGameMenuOverlay* inGameMenuOverlay; +@property(nonatomic, strong) XeniaIOSWindowPositionOverlay* windowPositionOverlay; +@property(nonatomic, strong) + XeniaIOSControllerNavigationCoordinator* controllerNavigationCoordinator; +@property(nonatomic, strong) XeniaIOSDocumentImportCoordinator* documentImportCoordinator; +@property(nonatomic, strong) XeniaIOSTouchLayoutUICoordinator* touchLayoutCoordinator; +@property(nonatomic, strong) + XeniaIOSAchievementNotificationPresenter* achievementNotificationPresenter; +@property(nonatomic, strong) XeniaIOSStatusToastPresenter* statusToastPresenter; +@property(nonatomic, strong) XeniaTouchControlsOverlayView* touchControlsOverlay; +@property(nonatomic, assign) BOOL gameRunning; +@property(nonatomic, assign) BOOL gameStopInProgress; +@property(nonatomic, strong) NSTimer* jitPollTimer; +@property(nonatomic, assign) NSTimeInterval jitPollInterval; +@property(nonatomic, assign) NSTimeInterval jitPollStartedAt; +@property(nonatomic, assign) BOOL jitPollTimedOut; +@property(nonatomic, assign) BOOL jitAcquired; +- (BOOL)hasConnectedGameplayController; +- (BOOL)shouldBlockGameplayInput; +- (BOOL)shouldShowTouchControlsOverlay; +- (void)refreshLauncherGameSnapshots; +- (void)refreshImportedGamesAsync; +- (void)finishImportedGamesRefresh; +- (void)setTouchLayoutEditModeActive:(BOOL)active animated:(BOOL)animated; +- (void)finishTouchLayoutEditMode; +- (void)updateTouchControlsOverlayVisibilityAnimated:(BOOL)animated; +- (void)showStatusToast:(NSString*)message style:(XeniaIOSStatusToastStyle)style; +- (void)showStatusToastForMessage:(NSString*)message; +- (void)presentGameActionsSheetForIndex:(size_t)game_index; +- (void)presentTouchLayoutGamePicker; +- (void)performGameAction:(XeniaIOSGameAction)action forIndex:(size_t)game_index; +- (void)presentGameSettingsSheetForIndex:(size_t)game_index; +- (void)presentGameTouchLayoutSheetForIndex:(size_t)game_index; +- (void)presentCompatibilitySheetForIndex:(size_t)game_index; +- (void)presentManageContentSheetForIndex:(size_t)game_index; +- (void)presentDiscSelectionSheetForIndex:(size_t)game_index; +- (void)presentPatchesSheetForIndex:(size_t)game_index; +- (void)scheduleNextJITPoll; +- (void)pauseButtonTapped:(id)sender; +- (void)resumeGameTapped:(UIButton*)sender; +- (void)inGameEditControlsTapped:(UIButton*)sender; +- (void)inGameAchievementsTapped:(UIButton*)sender; +- (void)inGameSettingsTapped:(UIButton*)sender; +- (void)inGameLiveLogTapped:(UIButton*)sender; +- (void)exitGameTapped:(UIButton*)sender; +- (void)handleSettingsHubAction:(XeniaSettingsHubAction)action; +- (void)refreshInGameDisplayMenu; +- (void)applyDefaultTouchLayoutModel; +- (void)applyTouchLayoutModelForTitleID:(uint32_t)title_id; +- (void)presentTouchLayoutLibrarySheet; +- (void)presentRenameTouchLayoutSheet; +- (void)presentDeleteTouchLayoutSheet; +- (void)saveCurrentTouchLayoutCopy; +- (UIViewController*)topPresentedControllerForModalPresentation; +- (void)importTouchLayoutFromFile; +- (void)exportCurrentTouchLayout; +- (void)resetToOfficialTouchLayoutPreset; +- (void)saveTouchLayoutModelForTitleID:(uint32_t)title_id; +- (uint32_t)titleIDForGamePath:(const std::filesystem::path&)game_path; +- (BOOL)handleExternalTouchLayoutFileURL:(NSURL*)url; +- (BOOL)handleExternalTouchLayoutSchemeURL:(NSURL*)url; +- (void)presentPendingTouchLayoutInstallIfReady; +- (void)handleAppDidBecomeActive:(NSNotification*)note; +- (void)handleControllerConnectionChanged:(NSNotification*)note; +- (BOOL)isGuestDisplayUncapped; +- (void)setGuestDisplayUncapped:(BOOL)uncapped; +- (BOOL)isPresentLetterboxEnabled; +- (void)setPresentLetterboxEnabled:(BOOL)enabled; + +// Presents `rootController` as the standardized in-game/settings sheet: wraps it +// in a XeniaLandscapeNavigationController, applies the shared task-sheet chrome, +// suppresses the gameplay touch overlay while presented, and clears that +// suppression on both completion and interactive dismissal. Centralizes the +// boilerplate previously duplicated across the quick settings, live log and +// achievements presenters. Returns the autoreleased wrapping controller. +- (XeniaLandscapeNavigationController*) + presentPauseSheetWithRootController:(UIViewController*)rootController + preferredSize:(CGSize)preferredSize + preventInteractiveDismissal:(BOOL)preventInteractiveDismissal + completion:(void (^)(void))completion; +@end + +@implementation XeniaViewController { + std::vector discovered_games_; + NSDictionary* compat_data_; + CGSize last_collection_layout_size_; + uint64_t library_refresh_generation_; + BOOL compat_fetch_started_; + std::filesystem::path pending_external_launch_path_; + std::unique_ptr touch_runtime_model_; + BOOL touch_layout_edit_mode_active_; + BOOL gameplay_modal_presentation_pending_; + uint32_t active_game_title_id_; + std::string active_touch_layout_local_id_; + UITapGestureRecognizer* in_game_menu_tap_recognizer_; // owned by self.view +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor blackColor]; + self.jitAcquired = NO; + self.gameRunning = NO; + self.gameStopInProgress = NO; + last_collection_layout_size_ = CGSizeZero; + compat_fetch_started_ = NO; + touch_layout_edit_mode_active_ = NO; + gameplay_modal_presentation_pending_ = NO; + active_game_title_id_ = 0; + active_touch_layout_local_id_.clear(); + XeniaIOSControllerNavigationCoordinator* controller_navigation = + [[XeniaIOSControllerNavigationCoordinator alloc] initWithHost:self]; + self.controllerNavigationCoordinator = controller_navigation; + [controller_navigation release]; + XeniaIOSDocumentImportCoordinator* document_import = + [[XeniaIOSDocumentImportCoordinator alloc] initWithHost:self]; + self.documentImportCoordinator = document_import; + [document_import release]; + XeniaIOSTouchLayoutUICoordinator* touch_layout = + [[XeniaIOSTouchLayoutUICoordinator alloc] initWithHost:self]; + self.touchLayoutCoordinator = touch_layout; + [touch_layout release]; + XeniaIOSAchievementNotificationPresenter* achievement_notifications = + [[XeniaIOSAchievementNotificationPresenter alloc] init]; + self.achievementNotificationPresenter = achievement_notifications; + [achievement_notifications release]; + XeniaIOSStatusToastPresenter* status_toasts = [[XeniaIOSStatusToastPresenter alloc] init]; + self.statusToastPresenter = status_toasts; + [status_toasts release]; + // Create the Metal-backed rendering view. The frame is set explicitly via + // -applyMetalViewLayout in -viewDidLayoutSubviews so the user-chosen + // window scaling mode (Fit / Stretch / Zoom) and portrait position offset + // can be applied. autoresizingMask is intentionally NOT set — we manage + // the frame on every layout pass instead. clipsToBounds keeps the Zoom + // mode (which may extend the metal view past the parent in one dimension + // to maintain aspect) from leaking outside our view controller's view. + self.view.clipsToBounds = YES; + self.metalView = [[XeniaMetalView alloc] initWithFrame:self.view.bounds]; + self.metalView.contentScaleFactor = [UIScreen mainScreen].scale; + [self.view addSubview:self.metalView]; + + // Create the iOS touch overlay above the Metal view. Hidden by default; + // visibility is driven by -updateTouchControlsOverlayVisibilityAnimated:. + touch_runtime_model_ = std::make_unique(); + XeniaTouchControlsOverlayView* touch_overlay = + [[XeniaTouchControlsOverlayView alloc] initWithRuntimeModel:touch_runtime_model_.get()]; + touch_overlay.frame = self.view.bounds; + touch_overlay.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.touchControlsOverlay = touch_overlay; + __block XeniaViewController* block_self = self; + self.touchControlsOverlay.pauseHandler = ^{ + [block_self pauseButtonTapped:nil]; + }; + self.touchControlsOverlay.doneEditingHandler = ^{ + [block_self finishTouchLayoutEditMode]; + }; + self.touchControlsOverlay.layoutLibraryHandler = ^{ + [block_self presentTouchLayoutLibrarySheet]; + }; + self.touchControlsOverlay.layoutLibraryLoadHandler = ^(NSString* local_id) { + [block_self.touchLayoutCoordinator applyLayoutWithLocalID:local_id]; + }; + self.touchControlsOverlay.layoutLibrarySaveCopyHandler = ^{ + [block_self saveCurrentTouchLayoutCopy]; + }; + self.touchControlsOverlay.layoutLibraryRenameHandler = ^{ + [block_self presentRenameTouchLayoutSheet]; + }; + self.touchControlsOverlay.layoutLibraryDeleteHandler = ^{ + [block_self presentDeleteTouchLayoutSheet]; + }; + self.touchControlsOverlay.layoutLibraryImportHandler = ^{ + [block_self importTouchLayoutFromFile]; + }; + self.touchControlsOverlay.layoutLibraryExportHandler = ^{ + [block_self exportCurrentTouchLayout]; + }; + self.touchControlsOverlay.layoutLibraryResetHandler = ^{ + [block_self resetToOfficialTouchLayoutPreset]; + }; + [self.view addSubview:self.touchControlsOverlay]; + [touch_overlay release]; + + // Create the launcher overlay UI immediately. When JIT is missing, keep + // settings/navigation available but gate game launch with status. + [self setupLauncherOverlay]; + [self setupInGameMenuOverlay]; + UITapGestureRecognizer* tap = + [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(toggleInGameMenuTapped:)]; + tap.numberOfTapsRequired = 1; + tap.cancelsTouchesInView = NO; + [self.view addGestureRecognizer:tap]; + in_game_menu_tap_recognizer_ = tap; + [tap release]; + [self updateJITStatusIndicator]; + [self updateJITAvailabilityUI]; + [self refreshSignedInProfileUI]; + NSDictionary* cached_compat_data = xe_load_cached_compat_data(); + if (cached_compat_data) { + [compat_data_ release]; + compat_data_ = [cached_compat_data retain]; + } + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onCompatDataDidUpdate:) + name:kXeniaCompatDataDidUpdateNotification + object:nil]; + [self refreshImportedGames]; + + // Start polling for JIT. + [self startJITPoll]; + [self.controllerNavigationCoordinator start]; + + // Cold-launch "Open in XeniOS" with auto-StikDebug enabled backgrounds the + // app right after the URL is dispatched, killing any in-flight modal. + // Listening for the foreground transition lets us present the install + // confirm once we're back on screen. + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleAppDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleControllerConnectionChanged:) + name:GCControllerDidConnectNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleControllerConnectionChanged:) + name:GCControllerDidDisconnectNotification + object:nil]; +} + +- (void)handleAppDidBecomeActive:(NSNotification* __unused)note { + if (!self.jitAcquired && !self.jitPollTimer) { + [self startJITPoll]; + } + if (!self.gameRunning && self.launcherOverlayView.hidden == NO) { + [self refreshImportedGamesAsync]; + } + [self presentPendingTouchLayoutInstallIfReady]; +} + +- (void)handleControllerConnectionChanged:(NSNotification* __unused)note { + [self.controllerNavigationCoordinator refreshLauncherFocus]; + [self.controllerNavigationCoordinator refreshInGameFocus]; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self startCompatFetchIfNeeded]; + xe_request_current_orientation(self); + [self presentPendingTouchLayoutInstallIfReady]; +} + +- (void)startCompatFetchIfNeeded { + if (compat_fetch_started_) { + return; + } + compat_fetch_started_ = YES; + xe_fetch_compat_data(^(NSDictionary* data) { + if (!data) { + return; + } + if (self->compat_data_ && [self->compat_data_ isEqualToDictionary:data]) { + return; + } + std::filesystem::path focused_path; + const NSInteger focused_game_index = self.controllerNavigationCoordinator.focusedGameIndex; + const BOOL has_focused_path = + focused_game_index >= 0 && + focused_game_index < static_cast(self->discovered_games_.size()); + if (has_focused_path) { + focused_path = self->discovered_games_[static_cast(focused_game_index)].path; + } + [self->compat_data_ release]; + self->compat_data_ = [data retain]; + [self applyCompatDataToDiscoveredGames]; + SortDiscoveredGames(&self->discovered_games_); + if (has_focused_path) { + auto focused_it = std::find_if( + self->discovered_games_.begin(), self->discovered_games_.end(), + [&focused_path](const IOSDiscoveredGame& game) { return game.path == focused_path; }); + if (focused_it != self->discovered_games_.end()) { + [self.controllerNavigationCoordinator + setFocusedGameIndex:static_cast( + std::distance(self->discovered_games_.begin(), focused_it)) + scroll:NO]; + } + } + [self refreshLauncherGameSnapshots]; + [self.controllerNavigationCoordinator refreshLauncherFocus]; + }); +} + +- (void)onCompatDataDidUpdate:(NSNotification*)__unused notification { + NSDictionary* cached_compat_data = xe_load_cached_compat_data(); + if (!cached_compat_data) { + return; + } + std::filesystem::path focused_path; + const NSInteger focused_game_index = self.controllerNavigationCoordinator.focusedGameIndex; + const BOOL has_focused_path = + focused_game_index >= 0 && + focused_game_index < static_cast(discovered_games_.size()); + if (has_focused_path) { + focused_path = discovered_games_[static_cast(focused_game_index)].path; + } + [compat_data_ release]; + compat_data_ = [cached_compat_data retain]; + [self applyCompatDataToDiscoveredGames]; + SortDiscoveredGames(&discovered_games_); + if (has_focused_path) { + auto focused_it = std::find_if( + discovered_games_.begin(), discovered_games_.end(), + [&focused_path](const IOSDiscoveredGame& game) { return game.path == focused_path; }); + if (focused_it != discovered_games_.end()) { + [self.controllerNavigationCoordinator + setFocusedGameIndex:static_cast( + std::distance(discovered_games_.begin(), focused_it)) + scroll:NO]; + } + } + [self refreshLauncherGameSnapshots]; + [self.controllerNavigationCoordinator refreshLauncherFocus]; +} + +#pragma mark - Controller navigation host + +- (BOOL)controllerNavigationLauncherVisible { + return self.launcherOverlayView && !self.launcherOverlayView.hidden; +} + +- (BOOL)controllerNavigationLauncherActionsEnabled { + return self.launcherOverlayView.actionsEnabled; +} + +- (NSInteger)controllerNavigationGameCount { + return static_cast(discovered_games_.size()); +} + +- (NSInteger)controllerNavigationLauncherColumnCount { + return [self.launcherOverlayView columnCount]; +} + +- (NSInteger)controllerNavigationLauncherPageStep { + return [self.launcherOverlayView pageStep]; +} + +- (BOOL)controllerNavigationGameRunning { + return self.gameRunning; +} + +- (BOOL)controllerNavigationInGameMenuVisible { + return self.inGameMenuOverlay && !self.inGameMenuOverlay.hidden; +} + +- (BOOL)controllerNavigationInGameMenuActionEnabled:(XeniaIOSInGameMenuAction)action { + return [self.inGameMenuOverlay isActionEnabled:action]; +} + +- (UIViewController*)controllerNavigationPresentedController { + return self.presentedViewController; +} + +- (BOOL)controllerNavigationHasConnectedController { + return [self hasConnectedGameplayController]; +} + +- (BOOL)controllerNavigationReadEmulatorControllerState:(xe::hid::X_INPUT_STATE*)outState { + if (!self.appContext || !outState) { + return NO; + } + for (uint32_t user_index = 0; user_index < xe::XUserMaxUserCount; ++user_index) { + if (self.appContext->GetControllerState(user_index, outState)) { + return YES; + } + } + return NO; +} + +- (void)controllerNavigationApplyFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll { + [self.launcherOverlayView setFocusedGameIndex:index scroll:scroll]; +} + +- (void)controllerNavigationApplyLauncherFocusEnabled:(BOOL)enabled + settingsFocused:(BOOL)settingsFocused + profileFocused:(BOOL)profileFocused + importFocused:(BOOL)importFocused + libraryFocusActive:(BOOL)libraryFocusActive { + [self.launcherOverlayView setControllerNavigationEnabled:enabled + settingsFocused:settingsFocused + profileFocused:profileFocused + importFocused:importFocused + libraryFocusActive:libraryFocusActive]; +} + +- (void)controllerNavigationApplyInGameMenuFocusEnabled:(BOOL)enabled + focusedAction:(XeniaIOSInGameMenuAction)focusedAction { + [self.inGameMenuOverlay setControllerNavigationEnabled:enabled focusedAction:focusedAction]; +} + +- (void)controllerNavigationOpenSettings { + [self openSettingsTapped:nil]; +} + +- (void)controllerNavigationOpenProfile { + [self openProfileTapped:nil]; +} + +- (void)controllerNavigationImportGame { + [self openGameTapped:nil]; +} + +- (void)controllerNavigationManageGameAtIndex:(NSInteger)index { + if (index < 0 || index >= static_cast(discovered_games_.size())) { + return; + } + [self presentGameActionsSheetForIndex:static_cast(index)]; +} + +- (void)controllerNavigationLaunchGameAtIndex:(NSInteger)index { + if (index < 0 || index >= static_cast(discovered_games_.size())) { + return; + } + const IOSDiscoveredGame& game = discovered_games_[static_cast(index)]; + [self launchGameAtPath:game.path displayName:ToNSString(game.title)]; +} + +- (void)controllerNavigationShowInGameMenu { + [self showInGameMenuOverlayAnimated:YES]; +} + +- (void)controllerNavigationHideInGameMenu { + [self hideInGameMenuOverlay]; +} + +- (void)controllerNavigationPerformInGameMenuAction:(XeniaIOSInGameMenuAction)action { + [self.inGameMenuOverlay performAction:action]; +} + +// --------------------------------------------------------------------------- +// JIT polling -- starts responsive, then backs off to avoid burning battery +// forever when StikDebug / JIT is unavailable. +// --------------------------------------------------------------------------- +- (void)startJITPoll { + // Check immediately first. + if (xe_check_jit_available()) { + [self onJITAcquired]; + return; + } + + XELOGI("iOS: JIT not yet available, polling..."); + self.jitPollInterval = 0.5; + self.jitPollStartedAt = [NSDate timeIntervalSinceReferenceDate]; + self.jitPollTimedOut = NO; + [self scheduleNextJITPoll]; +} + +- (void)pollJIT:(NSTimer*)timer { + if (timer != self.jitPollTimer) { + return; + } + self.jitPollTimer = nil; + if (xe_check_jit_available()) { + [self onJITAcquired]; + return; + } + + const NSTimeInterval elapsed = [NSDate timeIntervalSinceReferenceDate] - self.jitPollStartedAt; + if (elapsed >= 300.0) { + self.jitPollTimedOut = YES; + [self updateJITStatusIndicator]; + NSString* message = @"JIT is still unavailable. Open StikDebug, then return here to retry."; + [self showStatusToast:message style:XeniaIOSStatusToastStyleWarning]; + return; + } + + self.jitPollInterval = std::min(self.jitPollInterval * 2.0, 10.0); + [self scheduleNextJITPoll]; +} + +- (void)scheduleNextJITPoll { + [self.jitPollTimer invalidate]; + self.jitPollTimer = [NSTimer scheduledTimerWithTimeInterval:self.jitPollInterval + target:self + selector:@selector(pollJIT:) + userInfo:nil + repeats:NO]; +} + +- (void)onJITAcquired { + [self.jitPollTimer invalidate]; + self.jitPollTimer = nil; + self.jitPollTimedOut = NO; + self.jitAcquired = YES; + XELOGI("iOS: JIT acquired!"); + [self updateJITStatusIndicator]; + [self updateJITAvailabilityUI]; + + std::filesystem::path queued_path = pending_external_launch_path_; + std::filesystem::path persisted_path = TakePendingExternalLaunchPathPreference(); + pending_external_launch_path_.clear(); + + if (!queued_path.empty() || !persisted_path.empty()) { + std::filesystem::path path_to_launch = !queued_path.empty() ? queued_path : persisted_path; + NSString* display_name = [self displayNameForGamePath:path_to_launch]; + if (!display_name || display_name.length == 0) { + display_name = ToNSString(path_to_launch.filename().string()); + } + XELOGI("iOS: Launching queued external request: {}", path_to_launch.string()); + [self launchGameAtPath:path_to_launch displayName:display_name]; + } +} + +// --------------------------------------------------------------------------- +// Launcher overlay — delegated to XeniaIOSLauncherOverlayView. +// --------------------------------------------------------------------------- +- (void)setupLauncherOverlay { + XeniaIOSLauncherOverlayView* launcher_overlay = + [[XeniaIOSLauncherOverlayView alloc] initWithFrame:self.view.bounds]; + self.launcherOverlayView = launcher_overlay; + [launcher_overlay release]; + [self.view addSubview:self.launcherOverlayView]; + + __unsafe_unretained XeniaViewController* unsafe_self = self; + self.launcherOverlayView.settingsHandler = ^{ + [unsafe_self openSettingsTapped:nil]; + }; + self.launcherOverlayView.profileHandler = ^{ + [unsafe_self openProfileTapped:nil]; + }; + self.launcherOverlayView.importHandler = ^{ + [unsafe_self openGameTapped:nil]; + }; + self.launcherOverlayView.gameLaunchedHandler = ^(NSUInteger gameIndex) { + if (gameIndex >= unsafe_self->discovered_games_.size()) { + return; + } + const IOSDiscoveredGame& game = unsafe_self->discovered_games_[gameIndex]; + [unsafe_self launchGameAtPath:game.path displayName:ToNSString(game.title)]; + }; + self.launcherOverlayView.compatibilityHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentCompatibilitySheetForIndex:gameIndex]; + }; + self.launcherOverlayView.gameSettingsHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentGameSettingsSheetForIndex:gameIndex]; + }; + self.launcherOverlayView.touchLayoutHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentGameTouchLayoutSheetForIndex:gameIndex]; + }; + self.launcherOverlayView.manageContentHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentManageContentSheetForIndex:gameIndex]; + }; + self.launcherOverlayView.discSelectionHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentDiscSelectionSheetForIndex:gameIndex]; + }; + self.launcherOverlayView.patchesHandler = ^(NSUInteger gameIndex) { + [unsafe_self presentPatchesSheetForIndex:gameIndex]; + }; + self.launcherOverlayView.copyLaunchURLHandler = ^(NSUInteger gameIndex) { + [unsafe_self copyLaunchURLForGameAtIndex:gameIndex]; + }; + + // Route the public statusLabel to the overlay's internal label. + self.statusLabel = self.launcherOverlayView.statusLabel; + + // Allocated but off-screen — existing code can set .text without crashing. + UILabel* signed_in_profile_label = [[UILabel alloc] init]; + self.signedInProfileLabel = signed_in_profile_label; + [signed_in_profile_label release]; +} + +- (void)refreshLauncherGameSnapshots { + NSMutableArray* snapshots = + [NSMutableArray arrayWithCapacity:discovered_games_.size()]; + for (const IOSDiscoveredGame& game : discovered_games_) { + XeniaIOSLauncherGameSnapshot* snapshot = [[XeniaIOSLauncherGameSnapshot alloc] init]; + snapshot.title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + snapshot.titleId = game.title_id; + snapshot.hasCompatInfo = game.has_compat_info; + snapshot.compatStatus = ToNSString(game.compat_status); + if (!game.content_type_name.empty() && + (game.content_type == xe::XContentType::kArcadeTitle || + game.content_type == xe::XContentType::kGameDemo || + game.content_type == xe::XContentType::kCommunityGame)) { + snapshot.contentTypeName = ToNSString(game.content_type_name); + } else if (game.discs.size() > 1) { + snapshot.contentTypeName = [NSString stringWithFormat:@"%zu Discs", game.discs.size()]; + } + snapshot.supportsCompatibility = + game.title_id != 0 && xe_game_system_supports_compatibility(game.system); + snapshot.supportsManageContent = + game.title_id != 0 && xe_game_system_supports_manage_content(game.system); + snapshot.supportsDiscSelection = game.discs.size() > 1; + snapshot.discCount = game.discs.size(); + snapshot.supportsPatches = self.appContext && game.title_id != 0; + snapshot.supportsRemoteArt = xe_game_system_supports_remote_art(game.system); + if (!game.icon_data.empty()) { + snapshot.iconData = [NSData dataWithBytes:game.icon_data.data() length:game.icon_data.size()]; + } + [snapshots addObject:snapshot]; + [snapshot release]; + } + [self.launcherOverlayView setGames:snapshots]; + [self.launcherOverlayView + setFocusedGameIndex:self.controllerNavigationCoordinator.focusedGameIndex + scroll:NO]; +} + +- (void)setupInGameMenuOverlay { + XeniaIOSInGameMenuOverlay* overlay = + [[XeniaIOSInGameMenuOverlay alloc] initWithFrame:self.view.bounds]; + self.inGameMenuOverlay = overlay; + [overlay release]; + [self.view addSubview:self.inGameMenuOverlay]; + + __unsafe_unretained XeniaViewController* unsafe_self = self; + self.inGameMenuOverlay.resumeHandler = ^{ + [unsafe_self resumeGameTapped:nil]; + }; + self.inGameMenuOverlay.editControlsHandler = ^{ + [unsafe_self inGameEditControlsTapped:nil]; + }; + self.inGameMenuOverlay.achievementsHandler = ^{ + [unsafe_self inGameAchievementsTapped:nil]; + }; + self.inGameMenuOverlay.settingsHandler = ^{ + [unsafe_self inGameSettingsTapped:nil]; + }; + self.inGameMenuOverlay.liveLogHandler = ^{ + [unsafe_self inGameLiveLogTapped:nil]; + }; + self.inGameMenuOverlay.exitHandler = ^{ + [unsafe_self exitGameTapped:nil]; + }; + self.inGameMenuOverlay.graphicsHandler = ^{ + [unsafe_self presentQuickSettings]; + }; + [self refreshInGameDisplayMenu]; +} + +- (void)refreshInGameDisplayMenu { + self.inGameMenuOverlay.displayMenu = [self buildInGameDisplayMenu]; +} + +- (void)toggleInGameMenuTapped:(UITapGestureRecognizer*)recognizer { + if (recognizer.state != UIGestureRecognizerStateRecognized) { + return; + } + if (self.launcherOverlayView.hidden == NO || !self.gameRunning || self.presentedViewController) { + return; + } + + // If the in-game menu is showing AND the tap landed on one of its buttons, + // do NOT toggle the menu — let the button handle its own action. Without + // this guard, opening the Display button's UIMenu (or any other menu-button + // in the panel) would also dismiss the surrounding in-game menu overlay, + // leaving the user with a floating UIMenu over empty space and forcing + // them to tap-to-reopen afterwards. Tap recognizer was originally added + // with cancelsTouchesInView=NO so the button still receives the tap. + if (!self.inGameMenuOverlay.hidden) { + CGPoint tap_in_overlay = [recognizer locationInView:self.inGameMenuOverlay]; + UIView* hit = [self.inGameMenuOverlay hitTest:tap_in_overlay withEvent:nil]; + if (hit && hit != self.inGameMenuOverlay) { + return; + } + } + + if (self.inGameMenuOverlay.hidden) { + [self showInGameMenuOverlayAnimated:YES]; + } else { + [self hideInGameMenuOverlay]; + } +} + +- (void)showInGameMenuOverlayAnimated:(BOOL)animated { + if (!self.inGameMenuOverlay.hidden) { + return; + } + + if (touch_layout_edit_mode_active_) { + [self saveTouchLayoutModelForTitleID:active_game_title_id_]; + [self setTouchLayoutEditModeActive:NO animated:NO]; + } + + [self refreshInGameDisplayMenu]; + [self.inGameMenuOverlay setOverlayVisible:YES animated:animated completion:nil]; + [self.controllerNavigationCoordinator focusDefaultInGameAction]; + [self updateTouchControlsOverlayVisibilityAnimated:NO]; +} + +- (void)hideInGameMenuOverlay { + if (self.inGameMenuOverlay.hidden) { + return; + } + [self.inGameMenuOverlay setOverlayVisible:NO + animated:YES + completion:^(__unused BOOL finished) { + [self.controllerNavigationCoordinator refreshInGameFocus]; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; +} + +- (void)resumeGameTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; +} + +- (void)inGameEditControlsTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; + [self setTouchLayoutEditModeActive:YES animated:YES]; +} + +- (void)inGameAchievementsTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; + [self presentAchievementsForUserIndex:0 titleID:active_game_title_id_ completion:nil]; +} + +- (void)inGameSettingsTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; + [self openSettingsTapped:nil]; +} + +- (XeniaLandscapeNavigationController*) + presentPauseSheetWithRootController:(UIViewController*)rootController + preferredSize:(CGSize)preferredSize + preventInteractiveDismissal:(BOOL)preventInteractiveDismissal + completion:(void (^)(void))completion { + if (self.gameRunning) { + gameplay_modal_presentation_pending_ = YES; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + } + XeniaLandscapeNavigationController* nav = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:rootController]; + XEConfigureTaskSheet(nav, self.view, preferredSize, preventInteractiveDismissal); + nav.presentationController.delegate = self; + UIViewController* presenter = [self topPresentedControllerForModalPresentation] ?: self; + [presenter presentViewController:nav + animated:YES + completion:^{ + gameplay_modal_presentation_pending_ = NO; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + if (completion) { + completion(); + } + }]; + return [nav autorelease]; +} + +- (void)presentQuickSettings { + [self hideInGameMenuOverlay]; + XeniaIOSQuickSettingsViewController* quickVC = + [[XeniaIOSQuickSettingsViewController alloc] init]; + [self presentPauseSheetWithRootController:quickVC + preferredSize:CGSizeMake(480.0, 560.0) + preventInteractiveDismissal:NO + completion:nil]; + [quickVC release]; +} + +- (void)inGameLiveLogTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; + XeniaLogViewController* log_vc = [[XeniaLogViewController alloc] init]; + [self presentPauseSheetWithRootController:log_vc + preferredSize:CGSizeMake(560.0, 620.0) + preventInteractiveDismissal:NO + completion:nil]; + [log_vc release]; +} + +- (void)exitGameTapped:(UIButton*)sender { + [self hideInGameMenuOverlay]; + if (self.gameStopInProgress) { + self.statusLabel.text = @""; + [self showStatusToast:@"Stopping game... Please wait." style:XeniaIOSStatusToastStyleInfo]; + return; + } + if (!self.appContext) { + return; + } + + self.gameStopInProgress = YES; + self.gameRunning = NO; + self.launcherOverlayView.hidden = NO; + self.launcherOverlayView.alpha = 1.0; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + xe_request_current_orientation(self); + self.statusLabel.text = @""; + [self showStatusToast:@"Stopping game..." style:XeniaIOSStatusToastStyleInfo]; + + xe::ui::IOSWindowedAppContext* app_context = self.appContext; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + BOOL requested_stop = app_context->TerminateCurrentGame() ? YES : NO; + if (requested_stop) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + self.gameStopInProgress = NO; + }); + }); +} + +- (void)refreshSignedInProfileUI { + if (!self.appContext) { + self.launcherOverlayView.actionsEnabled = NO; + self.launcherOverlayView.alpha = 0.5; + self.signedInProfileLabel.text = @"Profile system unavailable"; + return; + } + + self.launcherOverlayView.actionsEnabled = YES; + self.launcherOverlayView.alpha = 1.0; + + const auto profiles = self.appContext->ListProfiles(); + const xe::ui::IOSProfileSummary* signed_in_profile = nullptr; + for (const auto& profile : profiles) { + if (profile.signed_in) { + signed_in_profile = &profile; + break; + } + } + + if (signed_in_profile) { + self.signedInProfileLabel.text = + [NSString stringWithFormat:@"Signed in: %@", ToNSString(signed_in_profile->gamertag)]; + } else if (profiles.empty()) { + self.signedInProfileLabel.text = @"No local profile yet"; + } else { + self.signedInProfileLabel.text = @"No profile signed in"; + } +} + +- (void)presentProfileCreateAlert { + // Under MRC, `__weak` is unavailable; rely on block strong captures. + // Use `__block` for the alert to avoid a retain-cycle: alert -> action -> handler -> alert. + __block UIAlertController* create_alert = + [UIAlertController alertControllerWithTitle:@"Create Profile" + message:@"Enter a gamertag (1-15 characters)." + preferredStyle:UIAlertControllerStyleAlert]; + [create_alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.placeholder = @"Gamertag"; + text_field.autocapitalizationType = UITextAutocapitalizationTypeWords; + text_field.autocorrectionType = UITextAutocorrectionTypeNo; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + }]; + [create_alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + [create_alert + addAction:[UIAlertAction + actionWithTitle:@"Create" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + if (!self.appContext) { + return; + } + UITextField* text_field = create_alert.textFields.firstObject; + NSString* raw_text = text_field.text ?: @""; + NSString* trimmed = + [raw_text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0 || !self.appContext) { + return; + } + auto* app_context = self.appContext; + if (!app_context) { + return; + } + NSString* gamertag = [[trimmed copy] autorelease]; + create_alert = nil; + uint64_t xuid = + app_context->CreateProfile(std::string([gamertag UTF8String])); + if (!xuid) { + UIAlertController* failure = [UIAlertController + alertControllerWithTitle:@"Profile Not Created" + message:@"Profile could not be created. " + @"Please try again." + preferredStyle:UIAlertControllerStyleAlert]; + [failure addAction:[UIAlertAction + actionWithTitle:@"OK" + style:UIAlertActionStyleCancel + handler:nil]]; + [self presentViewController:failure animated:YES completion:nil]; + return; + } + BOOL signed_in = app_context->SignInProfile(xuid); + if (!signed_in) { + [self showStatusToast:@"Failed to sign in with the new profile." + style:XeniaIOSStatusToastStyleError]; + return; + } + [self refreshSignedInProfileUI]; + [self showStatusToast:[NSString stringWithFormat:@"Signed in as %@.", + gamertag] + style:XeniaIOSStatusToastStyleSuccess]; + }]]; + [self presentViewController:create_alert animated:YES completion:nil]; +} + +- (void)presentSettingsDestinationWithInitialSection:(XeniaSettingsInitialSection)initial_section + preventInteractiveDismissal:(BOOL)prevent_interactive_dismissal { + if (!self.appContext) { + return; + } + + if (self.gameRunning) { + gameplay_modal_presentation_pending_ = YES; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + } + + __block XeniaViewController* unsafe_self = self; + XeniaSettingsStatusHandler status_handler = ^(NSString* status_message) { + [unsafe_self refreshSignedInProfileUI]; + if (status_message.length > 0) { + [unsafe_self showStatusToastForMessage:status_message]; + } + }; + void (^dismissal_handler)(void) = ^{ + gameplay_modal_presentation_pending_ = NO; + [unsafe_self updateTouchControlsOverlayVisibilityAnimated:YES]; + }; + XeniaSettingsHubActionHandler action_handler = ^(XeniaSettingsHubAction action) { + [unsafe_self handleSettingsHubAction:action]; + }; + + const BOOL use_workspace = + UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad && + self.traitCollection.horizontalSizeClass != UIUserInterfaceSizeClassCompact; + + UIViewController* destination = nil; + if (use_workspace) { + XeniaSettingsWorkspaceViewController* workspace = + [[XeniaSettingsWorkspaceViewController alloc] initWithAppContext:self.appContext + onStatus:status_handler + initialSection:initial_section]; + workspace.dismissalHandler = dismissal_handler; + workspace.actionHandler = action_handler; + destination = workspace; + } else { + XeniaSettingsHubViewController* hub = + [[XeniaSettingsHubViewController alloc] initWithAppContext:self.appContext + onStatus:status_handler + initialSection:initial_section]; + hub.showsCloseButton = YES; + hub.dismissalHandler = dismissal_handler; + hub.actionHandler = action_handler; + XeniaLandscapeNavigationController* nav = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:hub]; + [hub release]; + destination = nav; + } + + XEConfigureDestinationControllerPresentation( + destination, self.view, use_workspace ? CGSizeMake(920.0, 760.0) : CGSizeMake(620.0, 760.0), + prevent_interactive_dismissal); + destination.presentationController.delegate = self; + + UIViewController* presenter = [self topPresentedControllerForModalPresentation] ?: self; + [presenter presentViewController:destination + animated:YES + completion:^{ + gameplay_modal_presentation_pending_ = NO; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; + [destination release]; +} + +- (void)openProfileTapped:(UIButton*)sender { + (void)sender; + if (!self.appContext) { + return; + } + + if (self.gameRunning) { + gameplay_modal_presentation_pending_ = YES; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + } + + __block XeniaViewController* unsafe_self = self; + IOSProfileStatusHandler status_handler = ^(NSString* status_message) { + [unsafe_self refreshSignedInProfileUI]; + if (status_message.length > 0) { + [unsafe_self showStatusToastForMessage:status_message]; + } + }; + + XeniaProfileViewController* profile = + [[XeniaProfileViewController alloc] initWithAppContext:self.appContext + onStatus:status_handler]; + profile.showsDismissButton = YES; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:profile]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(520.0, 680.0), + YES); + navigation_controller.presentationController.delegate = self; + + UIViewController* presenter = [self topPresentedControllerForModalPresentation] ?: self; + [presenter presentViewController:navigation_controller + animated:YES + completion:^{ + gameplay_modal_presentation_pending_ = NO; + [unsafe_self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; + [navigation_controller release]; + [profile release]; +} + +- (void)dismissPresentedDestinationForSettingsAction:(void (^)(void))completion { + void (^finish)(void) = ^{ + gameplay_modal_presentation_pending_ = NO; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + if (completion) { + completion(); + } + }; + + if (self.presentedViewController) { + [self dismissViewControllerAnimated:YES completion:finish]; + } else { + finish(); + } +} + +- (void)handleSettingsHubAction:(XeniaSettingsHubAction)action { + switch (action) { + case XeniaSettingsHubActionOpenTouchLayoutLibrary: + [self presentTouchLayoutLibrarySheet]; + break; + case XeniaSettingsHubActionChooseGameTouchLayout: { + __block XeniaViewController* unsafe_self = self; + [self dismissPresentedDestinationForSettingsAction:^{ + [unsafe_self presentTouchLayoutGamePicker]; + }]; + } break; + case XeniaSettingsHubActionEditTouchControls: { + if (!self.gameRunning) { + XEPresentOKAlert([self topPresentedControllerForModalPresentation] ?: self, + @"Game Required", + @"Launch a game before editing the running touch layout."); + return; + } + __block XeniaViewController* unsafe_self = self; + [self dismissPresentedDestinationForSettingsAction:^{ + [unsafe_self inGameEditControlsTapped:nil]; + }]; + } break; + case XeniaSettingsHubActionImportTouchLayout: + [self importTouchLayoutFromFile]; + break; + case XeniaSettingsHubActionExportTouchLayout: + [self exportCurrentTouchLayout]; + break; + case XeniaSettingsHubActionResetTouchLayout: + [self resetToOfficialTouchLayoutPreset]; + break; + case XeniaSettingsHubActionImportGame: + [self.documentImportCoordinator presentGameImportPicker]; + break; + case XeniaSettingsHubActionRefreshLibrary: + [self refreshImportedGames]; + break; + case XeniaSettingsHubActionNone: + default: + break; + } +} + +- (void)presentSystemSigninPromptForUserIndex:(uint32_t)user_index + usersNeeded:(uint32_t)users_needed + completion:(void (^)(BOOL success))completion { + if (!self.appContext) { + if (completion) { + completion(NO); + } + return; + } + + __block BOOL finished = NO; + void (^finish)(BOOL) = ^(BOOL success) { + if (finished) { + return; + } + finished = YES; + [self refreshSignedInProfileUI]; + if (completion) { + completion(success); + } + }; + + auto profiles = self.appContext->ListProfiles(); + void (^present_create_alert)(void) = ^{ + if (!self.appContext) { + finish(NO); + return; + } + + // Under MRC, use `__block` to avoid a retain-cycle: alert -> action -> handler -> alert. + __block UIAlertController* create_alert = + [UIAlertController alertControllerWithTitle:@"Create Profile" + message:@"Enter a gamertag (1-15 characters)." + preferredStyle:UIAlertControllerStyleAlert]; + [create_alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.placeholder = @"Gamertag"; + text_field.autocapitalizationType = UITextAutocapitalizationTypeWords; + text_field.autocorrectionType = UITextAutocorrectionTypeNo; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + }]; + [create_alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:^(__unused UIAlertAction* action) { + finish(NO); + }]]; + [create_alert + addAction: + [UIAlertAction + actionWithTitle:@"Create" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + UITextField* text_field = create_alert.textFields.firstObject; + NSString* raw_text = text_field.text ?: @""; + NSString* trimmed = + [raw_text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0 || !self.appContext) { + finish(NO); + return; + } + auto* app_context = self.appContext; + if (!app_context) { + finish(NO); + return; + } + NSString* gamertag = [[trimmed copy] autorelease]; + create_alert = nil; + uint64_t xuid = + app_context->CreateProfile(std::string([gamertag UTF8String])); + if (!xuid) { + [self showStatusToast:@"Profile could not be created. Please try again." + style:XeniaIOSStatusToastStyleError]; + finish(NO); + return; + } + BOOL signed_in = app_context->SignInProfile(xuid); + if (signed_in) { + [self showStatusToast:[NSString stringWithFormat:@"Signed in as %@.", + gamertag] + style:XeniaIOSStatusToastStyleSuccess]; + } else { + [self showStatusToast:@"Failed to sign in with the new profile." + style:XeniaIOSStatusToastStyleError]; + } + finish(signed_in); + }]]; + + UIViewController* presenter = self; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + [presenter presentViewController:create_alert animated:YES completion:nil]; + }; + + if (profiles.empty()) { + present_create_alert(); + return; + } + + NSString* message = [NSString stringWithFormat:@"Select profile (needs %u user%@).", users_needed, + users_needed == 1 ? @"" : @"s"]; + UIAlertController* sheet = + [UIAlertController alertControllerWithTitle:@"Select Profile" + message:message + preferredStyle:UIAlertControllerStyleActionSheet]; + + [sheet addAction:[UIAlertAction actionWithTitle:@"Create Profile" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + present_create_alert(); + }]]; + + for (const auto& profile : profiles) { + NSString* gamertag = ToNSString(profile.gamertag); + NSString* title = gamertag; + if (profile.signed_in) { + title = [title stringByAppendingString:@" (Signed In)"]; + } + uint64_t xuid = profile.xuid; + [sheet addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + if (!self.appContext) { + finish(NO); + return; + } + BOOL signed_in = self.appContext->SignInProfile(xuid); + finish(signed_in); + }]]; + } + + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:^(__unused UIAlertAction* action) { + finish(NO); + }]]; + + UIPopoverPresentationController* popover = sheet.popoverPresentationController; + if (popover) { + popover.sourceView = self.view; + popover.sourceRect = + CGRectMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds), 1.0, 1.0); + popover.permittedArrowDirections = 0; + } + + UIViewController* presenter = self; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + [presenter presentViewController:sheet animated:YES completion:nil]; +} + +- (void)presentAchievementsForUserIndex:(uint32_t)user_index + titleID:(uint32_t)title_id + completion:(void (^)(BOOL success))completion { + if (!self.appContext) { + if (completion) { + completion(NO); + } + return; + } + + XeniaAchievementsViewController* achievements_vc = + [[XeniaAchievementsViewController alloc] initWithAppContext:self.appContext + userIndex:user_index + titleID:title_id]; + __block XeniaViewController* unsafe_self = self; + achievements_vc.dismissalHandler = ^{ + [unsafe_self updateTouchControlsOverlayVisibilityAnimated:YES]; + }; + [self presentPauseSheetWithRootController:achievements_vc + preferredSize:CGSizeMake(560.0, 620.0) + preventInteractiveDismissal:NO + completion:^{ + if (completion) { + completion(YES); + } + }]; + [achievements_vc release]; +} + +- (void)presentAchievementNotification:(const xe::ui::AchievementNotificationPayload&)payload { + [self.achievementNotificationPresenter presentPayload:payload inView:self.view]; +} + +- (void)presentSystemKeyboardPromptWithTitle:(NSString*)title + description:(NSString*)description + defaultText:(NSString*)default_text + completion:(void (^)(BOOL cancelled, NSString* text))completion { + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title.length ? title : @"Input Required" + message:description + preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.text = default_text ?: @""; + text_field.autocapitalizationType = UITextAutocapitalizationTypeNone; + text_field.autocorrectionType = UITextAutocorrectionTypeNo; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + }]; + + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:^(__unused UIAlertAction* action) { + if (completion) { + completion(YES, @""); + } + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + UITextField* text_field = alert.textFields.firstObject; + NSString* text = text_field.text ?: @""; + if (completion) { + completion(NO, text); + } + }]]; + + UIViewController* presenter = self; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + [presenter presentViewController:alert animated:YES completion:nil]; +} + +- (void)updateJITStatusIndicator { + [self.launcherOverlayView setJITAcquired:self.jitAcquired]; + if (self.jitAcquired) { + [self.launcherOverlayView setJITStatusText:@"JIT Enabled"]; + } else if (self.jitPollTimedOut) { + [self.launcherOverlayView + setJITStatusText:@"JIT unavailable. Open StikDebug, then restart polling."]; + } else { + [self.launcherOverlayView setJITStatusText:xe_jit_waiting_status_message()]; + } +} + +- (void)updateJITAvailabilityUI { + [self.launcherOverlayView setJITAcquired:self.jitAcquired]; +} + +- (std::filesystem::path)importedGamesDirectory { + return IOSImportedGamesDirectory(); +} + +- (std::filesystem::path)importGameIntoLibrary:(NSURL*)source_url error:(NSError**)error { + return ImportGameIntoIOSLibrary(source_url, error); +} + +- (void)refreshImportedGames { + ++library_refresh_generation_; + // Load cached title names populated by previous game launches. + NSString* caches_dir = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + NSString* names_path = [caches_dir stringByAppendingPathComponent:@"title-names.plist"]; + NSDictionary* title_name_cache = [[NSDictionary dictionaryWithContentsOfFile:names_path] retain]; + + discovered_games_ = ScanIOSGameLibrary(title_name_cache); + [title_name_cache release]; + + [self finishImportedGamesRefresh]; +} + +- (void)refreshImportedGamesAsync { + const uint64_t refresh_generation = ++library_refresh_generation_; + NSString* caches_dir = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + NSString* names_path = [caches_dir stringByAppendingPathComponent:@"title-names.plist"]; + NSDictionary* title_name_cache = [[NSDictionary dictionaryWithContentsOfFile:names_path] retain]; + + __unsafe_unretained XeniaViewController* unsafe_self = self; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + auto* scanned_games = new std::vector(ScanIOSGameLibrary(title_name_cache)); + [title_name_cache release]; + dispatch_async(dispatch_get_main_queue(), ^{ + std::unique_ptr> scanned_games_owner(scanned_games); + if (refresh_generation != unsafe_self->library_refresh_generation_) { + return; + } + unsafe_self->discovered_games_ = std::move(*scanned_games_owner); + [unsafe_self finishImportedGamesRefresh]; + }); + }); +} + +- (void)finishImportedGamesRefresh { + [self applyCompatDataToDiscoveredGames]; + SortDiscoveredGames(&discovered_games_); + + NSInteger focused_game_index = self.controllerNavigationCoordinator.focusedGameIndex; + if (discovered_games_.empty()) { + focused_game_index = -1; + } else if (focused_game_index < 0 || + focused_game_index >= static_cast(discovered_games_.size())) { + focused_game_index = 0; + } + [self.controllerNavigationCoordinator setFocusedGameIndex:focused_game_index scroll:NO]; + [self refreshLauncherGameSnapshots]; + [self.controllerNavigationCoordinator refreshLauncherFocus]; +} + +- (void)applyCompatDataToDiscoveredGames { + ApplyIOSCompatibilityDataToDiscoveredGames(compat_data_, &discovered_games_); +} + +- (void)presentJITRequiredAlert { + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:@"JIT Not Detected" + message:xe_jit_not_detected_guidance_message() + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Open Settings" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + [self openSettingsTapped:nil]; + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleCancel + handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (NSString*)displayNameForGamePath:(const std::filesystem::path&)game_path { + for (const IOSDiscoveredGame& game : discovered_games_) { + if (game.path == game_path) { + return ToNSString(game.title); + } + for (const auto& disc : game.discs) { + if (disc.path == game_path) { + return ToNSString(game.title); + } + } + } + return nil; +} + +- (BOOL)findDiscoveredGameWithTitleID:(uint32_t)title_id + system:(const xe::ui::IOSGameSystem*)system_filter + path:(std::filesystem::path*)path_out + displayName:(NSString**)display_name_out { + if (!title_id || !path_out) { + return NO; + } + + auto find_match = [&]() -> const IOSDiscoveredGame* { + for (const IOSDiscoveredGame& game : discovered_games_) { + if (game.title_id == title_id && (!system_filter || game.system == *system_filter)) { + return &game; + } + } + return nullptr; + }; + + const IOSDiscoveredGame* match = find_match(); + if (!match) { + [self refreshImportedGames]; + match = find_match(); + } + if (!match) { + return NO; + } + + *path_out = match->path; + if (display_name_out) { + *display_name_out = ToNSString(match->title); + } + return YES; +} + +- (BOOL)requestAutomaticStikDebugJITHandoffForPendingLaunchPath: + (const std::filesystem::path*)launch_path { + if (!GetUserDefaultBool(kXeniaAutoOpenStikDebugOnLaunchPreferenceKey, false)) { + XELOGI("iOS: Automatic StikDebug handoff skipped (disabled)"); + return NO; + } + if (self.gameRunning || self.gameStopInProgress) { + XELOGI("iOS: Automatic StikDebug handoff skipped (game already running)"); + return NO; + } + // Don't bounce through StikDebug while a touch layout install confirm is + // pending. Layouts don't need JIT, and the app-switch round-trip kills the + // modal before the user can act on it. The handoff gets re-evaluated once + // the install confirm is dismissed. + if ([self.touchLayoutCoordinator hasPendingInstall]) { + XELOGI("iOS: Automatic StikDebug handoff deferred (touch layout install pending)"); + return NO; + } + if (self.jitAcquired || xe_check_jit_available()) { + if (!self.jitAcquired) { + [self onJITAcquired]; + } + XELOGI("iOS: Automatic StikDebug handoff skipped (JIT already available)"); + return NO; + } + + const double now = GetUnixTimeSeconds(); + const double last_attempt = + GetUserDefaultDouble(kXeniaLastAutoStikDebugAttemptTimestampPreferenceKey, 0.0); + if (last_attempt > 0.0 && (now - last_attempt) < kXeniaAutoStikDebugCooldownSeconds) { + XELOGI("iOS: Skipping automatic StikDebug handoff (cooldown active)"); + return NO; + } + + NSString* bundle_identifier = NSBundle.mainBundle.bundleIdentifier; + NSURL* stikdebug_url = xe_stikdebug_enable_jit_url_for_bundle_identifier(bundle_identifier); + if (!stikdebug_url) { + XELOGW("iOS: Unable to build StikDebug JIT handoff URL"); + return NO; + } + + UIApplication* application = [UIApplication sharedApplication]; + if (![application canOpenURL:stikdebug_url]) { + XELOGW("iOS: StikDebug URL scheme unavailable"); + [self showStatusToast:@"StikDebug is not installed or unavailable." + style:XeniaIOSStatusToastStyleError]; + if (launch_path && !launch_path->empty()) { + ClearPendingExternalLaunchPathPreference(); + } + return NO; + } + + if (launch_path && !launch_path->empty()) { + StorePendingExternalLaunchPathPreference(*launch_path); + } + + const BOOL has_pending_launch = launch_path && !launch_path->empty(); + SetUserDefaultDouble(kXeniaLastAutoStikDebugAttemptTimestampPreferenceKey, now); + [self showStatusToast:has_pending_launch ? @"Opening StikDebug to enable JIT..." + : @"Opening StikDebug for JIT..." + style:XeniaIOSStatusToastStyleInfo]; + XELOGI("iOS: Opening StikDebug handoff URL {}", stikdebug_url.absoluteString.UTF8String); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [application openURL:stikdebug_url + options:@{} + completionHandler:^(BOOL success) { + if (!success) { + XELOGW("iOS: Failed to open StikDebug handoff URL"); + [self showStatusToast:@"Failed to open StikDebug." + style:XeniaIOSStatusToastStyleError]; + if (has_pending_launch) { + ClearPendingExternalLaunchPathPreference(); + } + } + }]; + }); + return YES; +} + +- (void)evaluateAutomaticStikDebugJITHandoffIfNeeded { + [self requestAutomaticStikDebugJITHandoffForPendingLaunchPath:nullptr]; +} + +- (void)copyLaunchURLForGameAtIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + [self showStatusToast:@"Launch URL unavailable for this game." + style:XeniaIOSStatusToastStyleError]; + return; + } + + NSString* launch_url = xe_launch_url_for_title_id(game.title_id, game.system); + if (!launch_url || launch_url.length == 0) { + [self showStatusToast:@"Failed to build launch URL." style:XeniaIOSStatusToastStyleError]; + return; + } + + [UIPasteboard generalPasteboard].string = launch_url; + NSString* game_title = ToNSString(game.title); + NSString* message = game_title.length > 0 + ? [NSString stringWithFormat:@"Copied launch URL for %@.", game_title] + : @"Copied launch URL."; + [self showStatusToast:message style:XeniaIOSStatusToastStyleSuccess]; + XELOGI("iOS: Copied title-ID launch URL {}", [launch_url UTF8String]); +} + +- (BOOL)respondToExternalGameInfoRequestURL:(NSURL*)url { + NSString* callback_scheme = nil; + if (!IsExternalGameInfoRequestURL(url, &callback_scheme)) { + return NO; + } + + if (!callback_scheme || callback_scheme.length == 0) { + XELOGW("iOS: gameInfo request is missing callback scheme"); + [self showStatusToast:@"gameInfo request is missing a callback scheme." + style:XeniaIOSStatusToastStyleError]; + return YES; + } + + NSString* request_scheme = NormalizeURLToken(url.scheme); + if (request_scheme && [callback_scheme caseInsensitiveCompare:request_scheme] == NSOrderedSame) { + XELOGW("iOS: gameInfo callback scheme {} would loop back into XeniOS", + [callback_scheme UTF8String]); + [self showStatusToast:@"gameInfo callback scheme cannot point back to XeniOS." + style:XeniaIOSStatusToastStyleError]; + return YES; + } + + [self refreshImportedGames]; + + NSMutableArray* exported_games = [NSMutableArray array]; + size_t skipped_without_title_id = 0; + for (const IOSDiscoveredGame& game : discovered_games_) { + if (!game.title_id) { + ++skipped_without_title_id; + continue; + } + + NSString* title_id = ToNSString(FormatTitleID(game.title_id)); + NSString* title_name = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + NSString* icon_base64 = @""; + if (!game.icon_data.empty()) { + NSData* icon_data = [NSData dataWithBytes:game.icon_data.data() length:game.icon_data.size()]; + if (icon_data.length > 0) { + icon_base64 = [icon_data base64EncodedStringWithOptions:0] ?: @""; + } + } + + NSDictionary* entry = @{ + @"titleName" : title_name ?: @"", + @"version" : @"", + @"iconData" : icon_base64 ?: @"", + @"titleId" : title_id ?: @"", + @"id" : title_id ?: @"", + @"developer" : @"", + }; + [exported_games addObject:entry]; + } + + NSError* json_error = nil; + NSData* json_data = [NSJSONSerialization dataWithJSONObject:exported_games + options:0 + error:&json_error]; + if (!json_data || json_error) { + XELOGE("iOS: Failed serializing gameInfo payload: {}", + json_error.localizedDescription.UTF8String); + [self showStatusToast:@"Failed to build gameInfo payload." style:XeniaIOSStatusToastStyleError]; + return YES; + } + + NSString* games_payload = [json_data base64EncodedStringWithOptions:0]; + if (!games_payload || games_payload.length == 0) { + XELOGW("iOS: gameInfo payload encoding produced empty data"); + [self showStatusToast:@"Failed to encode gameInfo payload." + style:XeniaIOSStatusToastStyleError]; + return YES; + } + + NSURLComponents* callback_components = [[[NSURLComponents alloc] init] autorelease]; + callback_components.scheme = callback_scheme; + callback_components.host = xe_game_info_callback_provider(url); + callback_components.queryItems = @[ [NSURLQueryItem queryItemWithName:@"games" + value:games_payload] ]; + NSURL* callback_url = callback_components.URL; + if (!callback_url) { + XELOGE("iOS: Failed building gameInfo callback URL for scheme {}", + [callback_scheme UTF8String]); + [self showStatusToast:@"Failed to build gameInfo callback URL." + style:XeniaIOSStatusToastStyleError]; + return YES; + } + + const NSUInteger exported_count = exported_games.count; + NSString* callback_app = callback_scheme; + XELOGI( + "iOS: Returning {} games via {} (skipped {} without title IDs, payload {} bytes, URL chars " + "{})", + static_cast(exported_count), callback_url.absoluteString.UTF8String, + static_cast(skipped_without_title_id), static_cast(json_data.length), + static_cast(callback_url.absoluteString.length)); + + [[UIApplication sharedApplication] + openURL:callback_url + options:@{} + completionHandler:^(BOOL success) { + if (success) { + [self showStatusToast:[NSString stringWithFormat:@"Sent %lu games to %@.", + (unsigned long)exported_count, + callback_app] + style:XeniaIOSStatusToastStyleSuccess]; + return; + } + XELOGW("iOS: Failed opening gameInfo callback URL {}", + callback_url.absoluteString.UTF8String); + [self showStatusToast:[NSString + stringWithFormat:@"Failed to return library to %@.", callback_app] + style:XeniaIOSStatusToastStyleError]; + }]; + return YES; +} + +- (BOOL)handleExternalLaunchURL:(NSURL*)url { + if ([self respondToExternalGameInfoRequestURL:url]) { + return YES; + } + + // Touch-layout file URLs and xenios://touchlayout?... drops share the + // launch URL surface, so handle them before the game-launch branches. + if ([self handleExternalTouchLayoutFileURL:url]) { + return YES; + } + if ([self handleExternalTouchLayoutSchemeURL:url]) { + return YES; + } + + std::filesystem::path launch_path; + NSString* display_name = nil; + uint32_t title_id = 0; + xe::ui::IOSGameSystem title_system = xe::ui::IOSGameSystem::kXbox360; + bool title_system_present = false; + if (ExtractLaunchPathFromExternalURL(url, &launch_path) && !launch_path.empty()) { + display_name = [self displayNameForGamePath:launch_path]; + if (!display_name || display_name.length == 0) { + display_name = ToNSString(launch_path.filename().string()); + } + } else if (ExtractLaunchTitleIDFromExternalURL(url, &title_id, &title_system, + &title_system_present) && + title_id) { + if (![self findDiscoveredGameWithTitleID:title_id + system:title_system_present ? &title_system : nullptr + path:&launch_path + displayName:&display_name]) { + XELOGW("iOS: External launch title ID {:08X} was not found in Library", title_id); + [self showStatusToast:[NSString stringWithFormat:@"Title ID %08X was not found in Library.", + title_id] + style:XeniaIOSStatusToastStyleError]; + return NO; + } + } + + if (launch_path.empty()) { + NSString* absolute_url = [url absoluteString]; + XELOGW("iOS: External launch URL missing valid game target: {}", + absolute_url ? [absolute_url UTF8String] : ""); + [self showStatusToast:@"Launch URL missing a valid game target." + style:XeniaIOSStatusToastStyleError]; + return NO; + } + + if (!display_name || display_name.length == 0) { + display_name = ToNSString(launch_path.filename().string()); + } + + if (title_id) { + XELOGI("iOS: External game launch requested by title ID {:08X}: {}", title_id, + launch_path.string()); + } else { + XELOGI("iOS: External game launch requested: {}", launch_path.string()); + } + if (!self.jitAcquired) { + pending_external_launch_path_ = launch_path; + if (![self requestAutomaticStikDebugJITHandoffForPendingLaunchPath:&launch_path]) { + [self + showStatusToast:[NSString stringWithFormat:@"Waiting for JIT to launch: %@", display_name] + style:XeniaIOSStatusToastStyleInfo]; + } + return YES; + } + + [self launchGameAtPath:launch_path displayName:display_name]; + return YES; +} + +- (void)launchGameAtPath:(const std::filesystem::path&)game_path + displayName:(NSString*)display_name { + (void)display_name; + NSString* path_ns = ToNSString(game_path.string()); + std::error_code path_exists_ec; + if (!std::filesystem::exists(game_path, path_exists_ec)) { + [self showStatusToast:@"Game file is no longer available." + style:XeniaIOSStatusToastStyleError]; + [self refreshImportedGamesAsync]; + return; + } + + if (IsLikelyGodContainerFile(game_path)) { + auto metadata = xe::vfs::ExtractStfsMetadata(game_path); + if (metadata.has_value() && metadata->data_file_count > 0 && + !HasContentSidecarDataDirectory(game_path)) { + [self showStatusToast:@"Selected game is missing its .data folder." + style:XeniaIOSStatusToastStyleError]; + XEPresentOKAlert(self, @"Missing Game Data", + @"This GOD package needs its matching .data folder in the same directory. " + @"Transfer both the GOD file and its .data folder, then try again."); + return; + } + } + + if (!self.jitAcquired) { + [self presentJITRequiredAlert]; + return; + } + + if (self.gameStopInProgress || self.gameRunning) { + if (self.appContext) { + self.gameRunning = YES; + self.gameStopInProgress = NO; + active_game_title_id_ = [self titleIDForGamePath:game_path]; + [self applyTouchLayoutModelForTitleID:active_game_title_id_]; + xe_request_landscape_orientation(self); + [UIView animateWithDuration:0.3 + animations:^{ + self.launcherOverlayView.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + self.launcherOverlayView.hidden = YES; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; + self.appContext->LaunchGame(std::string([path_ns UTF8String])); + } else { + [self showStatusToast:@"Unable to queue launch (app context unavailable)." + style:XeniaIOSStatusToastStyleError]; + } + return; + } + + self.gameRunning = YES; + active_game_title_id_ = [self titleIDForGamePath:game_path]; + [self applyTouchLayoutModelForTitleID:active_game_title_id_]; + + xe_request_landscape_orientation(self); + [UIView animateWithDuration:0.3 + animations:^{ + self.launcherOverlayView.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + self.launcherOverlayView.hidden = YES; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; + + if (self.appContext) { + self.appContext->LaunchGame(std::string([path_ns UTF8String])); + } else { + [self showStatusToast:@"Unable to launch game (app context unavailable)." + style:XeniaIOSStatusToastStyleError]; + self.launcherOverlayView.hidden = NO; + self.launcherOverlayView.alpha = 1.0; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + } +} + +- (BOOL)installTitleUpdateAtPath:(NSString*)path + status:(NSString**)status_out + notTitleUpdate:(BOOL*)not_title_update_out { + if (status_out) { + *status_out = nil; + } + if (not_title_update_out) { + *not_title_update_out = NO; + } + if (!self.appContext) { + if (status_out) { + *status_out = @"App context unavailable."; + } + return NO; + } + + std::string status; + bool not_title_update = false; + BOOL success = self.appContext->InstallTitleUpdate(std::string([path UTF8String]), &status, + ¬_title_update); + if (status_out && !status.empty()) { + *status_out = ToNSString(status); + } + if (not_title_update_out) { + *not_title_update_out = not_title_update; + } + return success; +} + +- (void)presentGameActionsSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + XeniaIOSGameActionsViewController* actions_controller = + [[XeniaIOSGameActionsViewController alloc] + initWithGameTitle:game_title + titleID:game.title_id + supportsCompatibility:game.title_id != 0 && + xe_game_system_supports_compatibility(game.system) + supportsManageContent:game.title_id != 0 && + xe_game_system_supports_manage_content(game.system) + supportsDiscSelection:game.discs.size() > 1 + supportsPatches:self.appContext && game.title_id != 0]; + + __unsafe_unretained XeniaViewController* unsafe_self = self; + actions_controller.actionHandler = ^(XeniaIOSGameAction action) { + [unsafe_self dismissViewControllerAnimated:YES + completion:^{ + [unsafe_self performGameAction:action forIndex:game_index]; + }]; + }; + + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:actions_controller]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(520.0, 620.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [actions_controller release]; +} + +- (void)performGameAction:(XeniaIOSGameAction)action forIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + switch (action) { + case XeniaIOSGameActionPlay: { + const IOSDiscoveredGame& game = discovered_games_[game_index]; + [self launchGameAtPath:game.path displayName:ToNSString(game.title)]; + } break; + case XeniaIOSGameActionGameSettings: + [self presentGameSettingsSheetForIndex:game_index]; + break; + case XeniaIOSGameActionTouchLayout: + [self presentGameTouchLayoutSheetForIndex:game_index]; + break; + case XeniaIOSGameActionCompatibility: + [self presentCompatibilitySheetForIndex:game_index]; + break; + case XeniaIOSGameActionManageContent: + [self presentManageContentSheetForIndex:game_index]; + break; + case XeniaIOSGameActionLaunchDisc: + [self presentDiscSelectionSheetForIndex:game_index]; + break; + case XeniaIOSGameActionPatches: + [self presentPatchesSheetForIndex:game_index]; + break; + case XeniaIOSGameActionCopyLaunchURL: + [self copyLaunchURLForGameAtIndex:game_index]; + break; + } +} + +- (void)presentTouchLayoutGamePicker { + NSMutableArray* items = + [NSMutableArray arrayWithCapacity:discovered_games_.size()]; + for (size_t i = 0; i < discovered_games_.size(); ++i) { + const IOSDiscoveredGame& game = discovered_games_[i]; + if (!game.title_id) { + continue; + } + + NSString* title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + NSMutableArray* details = [NSMutableArray array]; + [details addObject:[NSString stringWithFormat:@"Title ID %@", + ToNSString(FormatTitleID(game.title_id))]]; + if (!game.content_type_name.empty()) { + [details addObject:ToNSString(game.content_type_name)]; + } else if (game.discs.size() > 1) { + [details addObject:[NSString stringWithFormat:@"%zu discs", game.discs.size()]]; + } + + [items addObject:[XeniaIOSGamePickerItem + itemWithTitle:title + subtitle:[details componentsJoinedByString:@" · "] + titleID:game.title_id + gameIndex:static_cast(i)]]; + } + + if (items.count == 0) { + XEPresentOKAlert(self, @"No Games Available", + @"Import a game with a title ID before editing a per-game touch layout."); + return; + } + + XeniaIOSGamePickerViewController* picker = + [[XeniaIOSGamePickerViewController alloc] + initWithTitle:@"Game Touch Layout" + prompt:@"Choose a title to edit before launch." + items:items]; + __unsafe_unretained XeniaViewController* unsafe_self = self; + picker.selectionHandler = ^(NSUInteger gameIndex) { + [unsafe_self dismissViewControllerAnimated:YES + completion:^{ + [unsafe_self presentGameTouchLayoutSheetForIndex:gameIndex]; + }]; + }; + + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:picker]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(560.0, 700.0), + NO); + UIViewController* presenter = [self topPresentedControllerForModalPresentation] ?: self; + [presenter presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [picker release]; +} + +- (void)presentGameSettingsSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + XEPresentOKAlert(self, @"Unavailable", + @"This item does not expose a title ID, so game settings cannot be saved."); + return; + } + + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + XeniaConfigViewController* config_controller = [[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kPerGame + style:UITableViewStyleInsetGrouped + gameTitleID:game.title_id + gameTitle:game_title]; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:config_controller]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(640.0, 760.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [config_controller release]; +} + +- (void)presentGameTouchLayoutSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + XEPresentOKAlert(self, @"Unavailable", + @"This item does not expose a title ID, so a title-specific touch layout " + @"cannot be saved."); + return; + } + + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + XeniaIOSTouchLayoutEditorViewController* editor_controller = + [[XeniaIOSTouchLayoutEditorViewController alloc] initWithTitleID:game.title_id + title:game_title]; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:editor_controller]; + navigation_controller.modalPresentationStyle = UIModalPresentationFullScreen; + navigation_controller.navigationBarHidden = YES; + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [editor_controller release]; +} + +- (void)presentCompatibilitySheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + XEPresentOKAlert(self, @"Unavailable", + @"This item does not expose a title ID, so compatibility details " + @"cannot be loaded."); + return; + } + + NSDictionary* compat_data = [compat_data_ objectForKey:XEFormatTitleIDHexUpper(game.title_id)]; + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + UIImage* hero_artwork = xe_cached_game_art(game.title_id); + if (!hero_artwork && !game.icon_data.empty()) { + NSData* data = [NSData dataWithBytes:game.icon_data.data() length:game.icon_data.size()]; + hero_artwork = [UIImage imageWithData:data]; + } + if (!hero_artwork && self.launcherOverlayView.gamesCollectionView) { + NSIndexPath* index_path = [NSIndexPath indexPathForItem:(NSInteger)game_index inSection:0]; + XeniaGameTileCell* tile = (XeniaGameTileCell*)[self.launcherOverlayView.gamesCollectionView + cellForItemAtIndexPath:index_path]; + if ([tile isKindOfClass:[XeniaGameTileCell class]]) { + hero_artwork = tile.iconView.image; + } + } + XeniaGameCompatibilityViewController* compatibility_controller = + [[XeniaGameCompatibilityViewController alloc] initWithTitleID:game.title_id + title:game_title + compatData:compat_data]; + if (hero_artwork) { + [compatibility_controller setHeroArtwork:hero_artwork]; + } + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] + initWithRootViewController:compatibility_controller]; + navigation_controller.view.backgroundColor = [XeniaTheme bgPrimary]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(620.0, 720.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [compatibility_controller release]; +} + +- (void)presentManageContentSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + XEPresentOKAlert( + self, @"Unavailable", + @"This item does not expose a title ID, so installed content cannot be managed."); + return; + } + + XeniaGameContentViewController* content_controller = [[XeniaGameContentViewController alloc] + initWithTitleID:game.title_id + title:(game.title.empty() ? ToNSString(game.path.stem().string()) + : ToNSString(game.title))host:self]; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:content_controller]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(600.0, 680.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [content_controller release]; +} + +- (void)presentDiscSelectionSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (game.discs.size() <= 1) { + [self launchGameAtPath:game.path displayName:ToNSString(game.title)]; + return; + } + + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + __unsafe_unretained XeniaViewController* unsafe_self = self; + XeniaGameDiscViewController* disc_controller = [[XeniaGameDiscViewController alloc] + initWithTitle:game_title + discs:game.discs + selectionHandler:^(NSString* path, NSString* label) { + if (!path.length) { + return; + } + NSString* display_name = + label.length ? [NSString stringWithFormat:@"%@ (%@)", game_title, label] : game_title; + [unsafe_self launchGameAtPath:std::filesystem::path([path UTF8String]) + displayName:display_name]; + }]; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:disc_controller]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(520.0, 560.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [disc_controller release]; +} + +- (void)presentPatchesSheetForIndex:(size_t)game_index { + if (game_index >= discovered_games_.size()) { + return; + } + + const IOSDiscoveredGame& game = discovered_games_[game_index]; + if (!game.title_id) { + XEPresentOKAlert(self, @"Unavailable", + @"This item does not expose a title ID, so patches cannot be loaded."); + return; + } + if (!self.appContext) { + XEPresentOKAlert(self, @"Unavailable", @"Patch management is unavailable."); + return; + } + + NSString* game_title = + game.title.empty() ? ToNSString(game.path.stem().string()) : ToNSString(game.title); + XeniaGamePatchesViewController* patches_controller = + [[XeniaGamePatchesViewController alloc] initWithTitleID:game.title_id + title:game_title + appContext:self.appContext]; + XeniaLandscapeNavigationController* navigation_controller = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:patches_controller]; + XEConfigureDestinationPresentation(navigation_controller, self.view, CGSizeMake(600.0, 680.0), + NO); + [self presentViewController:navigation_controller animated:YES completion:nil]; + [navigation_controller release]; + [patches_controller release]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + CGSize collection_size = self.launcherOverlayView.gamesCollectionView.bounds.size; + if (!CGSizeEqualToSize(collection_size, last_collection_layout_size_)) { + last_collection_layout_size_ = collection_size; + [self.launcherOverlayView.gamesCollectionView.collectionViewLayout invalidateLayout]; + } + // Keep scrollable content above the home indicator. The collection view's + // visual frame extends to the screen bottom (so the launcher background + // doesn't leave a white bar there), but cells should stop scrolling at the + // safe area to keep the bottom row clear of the home gesture zone. + CGFloat home_inset = self.view.safeAreaInsets.bottom; + UIEdgeInsets current = self.launcherOverlayView.gamesCollectionView.contentInset; + if (current.bottom != home_inset) { + current.bottom = home_inset; + self.launcherOverlayView.gamesCollectionView.contentInset = current; + UIEdgeInsets indicator = self.launcherOverlayView.gamesCollectionView.scrollIndicatorInsets; + indicator.bottom = home_inset; + self.launcherOverlayView.gamesCollectionView.scrollIndicatorInsets = indicator; + } + [self applyMetalViewLayout]; + // Notify the app context that the layout changed, so the window and + // presenter can update for rotation, split-view, or safe-area changes. + if (self.appContext) { + self.appContext->NotifyLayoutChanged(); + } +} + +- (void)refreshLauncherChromeForCurrentTraits { + [self.launcherOverlayView refreshChromeForCurrentTraits]; + [self.controllerNavigationCoordinator refreshLauncherFocus]; +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if ([self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + [self refreshLauncherChromeForCurrentTraits]; + } +} + +// External touch-layout install URL safety limits. 64 KB is well over the +// largest official preset and still small enough to reject hostile TOML drops +// before they can spend meaningful CPU time in the parser. +static constexpr NSUInteger kXeniaIOSTouchLayoutMaxBytes = 64 * 1024; +static constexpr NSUInteger kXeniaIOSTouchLayoutURLMaxLength = 2048; + +#pragma mark - Window scaling + +- (XeniaIOSWindowScalingMode)currentWindowScalingMode { + return XeniaIOSCurrentWindowScalingMode(); +} + +- (void)setCurrentWindowScalingMode:(XeniaIOSWindowScalingMode)mode { + XeniaIOSSetCurrentWindowScalingMode(mode); + [self.view setNeedsLayout]; + [self.view layoutIfNeeded]; +} + +- (BOOL)isGuestDisplayUncapped { + if (!self.appContext) { + return NO; + } + return !self.appContext->GetGuestDisplayRefreshCap(); +} + +- (void)setGuestDisplayUncapped:(BOOL)uncapped { + if (!self.appContext) { + return; + } + self.appContext->SetGuestDisplayRefreshCap(!uncapped); +} + +- (BOOL)isPresentLetterboxEnabled { + if (!self.appContext) { + return cvars::present_letterbox; + } + return self.appContext->GetPresentLetterbox(); +} + +- (void)setPresentLetterboxEnabled:(BOOL)enabled { + if (!self.appContext) { + cvars::present_letterbox = enabled; + return; + } + self.appContext->SetPresentLetterbox(enabled); +} + +- (CGFloat)currentGuestDisplayAspectRatio { + std::pair aspect = {16, 9}; + if (self.appContext) { + aspect = self.appContext->GetGuestDisplayAspectRatio(); + } + if (!aspect.first || !aspect.second) { + return 16.0 / 9.0; + } + const CGFloat aspect_ratio = + static_cast(aspect.first) / static_cast(aspect.second); + return std::isfinite(static_cast(aspect_ratio)) && aspect_ratio > 0.0 ? aspect_ratio + : 16.0 / 9.0; +} + +- (void)applyMetalViewLayout { + if (!self.metalView) { + return; + } + const CGRect parent = self.view.bounds; + if (CGRectIsEmpty(parent)) { + return; + } + const CGFloat guest_aspect = [self currentGuestDisplayAspectRatio]; + const CGFloat parent_aspect = parent.size.width / MAX(parent.size.height, 1.0); + const XeniaIOSWindowScalingMode mode = [self currentWindowScalingMode]; + self.metalView.xeniaDrawableAspectRatio = + mode == XeniaIOSWindowScalingModeStretch ? parent_aspect : guest_aspect; + CGRect frame = XeniaIOSMetalViewFrameForParent(parent, guest_aspect, mode, + XeniaIOSCurrentPortraitWindowOffset()); + + if (!CGRectEqualToRect(self.metalView.frame, frame)) { + self.metalView.frame = frame; + } +} + +- (UIMenu*)buildInGameDisplayMenu { + __unsafe_unretained XeniaViewController* unsafe_self = self; + const XeniaIOSWindowScalingMode current_mode = [self currentWindowScalingMode]; + + UIAction* fit_action = + [UIAction actionWithTitle:@"Fit (Preserve Aspect)" + image:[UIImage systemImageNamed:@"rectangle.center.inset.filled"] + identifier:nil + handler:^(__unused UIAction* action) { + [unsafe_self setCurrentWindowScalingMode:XeniaIOSWindowScalingModeFit]; + [unsafe_self refreshInGameDisplayMenu]; + }]; + fit_action.state = + current_mode == XeniaIOSWindowScalingModeFit ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIAction* stretch_action = [UIAction + actionWithTitle:@"Stretch (Fill, Ignore Aspect)" + image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"] + identifier:nil + handler:^(__unused UIAction* action) { + [unsafe_self setCurrentWindowScalingMode:XeniaIOSWindowScalingModeStretch]; + [unsafe_self refreshInGameDisplayMenu]; + }]; + stretch_action.state = current_mode == XeniaIOSWindowScalingModeStretch ? UIMenuElementStateOn + : UIMenuElementStateOff; + + UIAction* zoom_action = + [UIAction actionWithTitle:@"Zoom (Fill, Crop)" + image:[UIImage systemImageNamed:@"rectangle.fill"] + identifier:nil + handler:^(__unused UIAction* action) { + [unsafe_self setCurrentWindowScalingMode:XeniaIOSWindowScalingModeZoom]; + [unsafe_self refreshInGameDisplayMenu]; + }]; + zoom_action.state = + current_mode == XeniaIOSWindowScalingModeZoom ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIMenu* scaling_submenu = [UIMenu menuWithTitle:@"Scaling" + image:nil + identifier:nil + options:UIMenuOptionsDisplayInline + children:@[ fit_action, stretch_action, zoom_action ]]; + + const BOOL letterbox_enabled = [self isPresentLetterboxEnabled]; + UIAction* letterbox_action = + [UIAction actionWithTitle:@"Letterbox" + image:[UIImage systemImageNamed:@"rectangle.center.inset.filled"] + identifier:nil + handler:^(__unused UIAction* action) { + const BOOL next_enabled = ![unsafe_self isPresentLetterboxEnabled]; + [unsafe_self setPresentLetterboxEnabled:next_enabled]; + [unsafe_self refreshInGameDisplayMenu]; + }]; + letterbox_action.state = letterbox_enabled ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIMenu* presentation_submenu = [UIMenu menuWithTitle:@"Presentation" + image:nil + identifier:nil + options:UIMenuOptionsDisplayInline + children:@[ letterbox_action ]]; + + const BOOL display_uncapped = [self isGuestDisplayUncapped]; + UIAction* uncapped_action = + [UIAction actionWithTitle:@"Emulated Display Uncapped" + image:[UIImage systemImageNamed:@"speedometer"] + identifier:nil + handler:^(__unused UIAction* action) { + const BOOL next_uncapped = ![unsafe_self isGuestDisplayUncapped]; + [unsafe_self setGuestDisplayUncapped:next_uncapped]; + [unsafe_self refreshInGameDisplayMenu]; + }]; + uncapped_action.state = display_uncapped ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIMenu* refresh_submenu = [UIMenu menuWithTitle:@"Refresh Rate" + image:nil + identifier:nil + options:UIMenuOptionsDisplayInline + children:@[ uncapped_action ]]; + + // Portrait position controls — only meaningful in portrait + Fit mode where + // there's actual slack to drag the surface within. + const BOOL is_portrait = self.view.bounds.size.height >= self.view.bounds.size.width; + const BOOL position_available = is_portrait && current_mode == XeniaIOSWindowScalingModeFit; + UIAction* position_action = [UIAction actionWithTitle:@"Position in Portrait…" + image:[UIImage systemImageNamed:@"hand.draw"] + identifier:nil + handler:^(__unused UIAction* action) { + [unsafe_self enterWindowPositionMode]; + }]; + if (!position_available) { + position_action.attributes = UIMenuElementAttributesDisabled; + } + + UIAction* reset_action = + [UIAction actionWithTitle:@"Reset Position" + image:[UIImage systemImageNamed:@"arrow.counterclockwise"] + identifier:nil + handler:^(__unused UIAction* action) { + [unsafe_self resetWindowPosition]; + }]; + + UIMenu* position_submenu = [UIMenu menuWithTitle:@"Position" + image:nil + identifier:nil + options:UIMenuOptionsDisplayInline + children:@[ position_action, reset_action ]]; + + return [UIMenu + menuWithTitle:@"Display" + children:@[ scaling_submenu, presentation_submenu, refresh_submenu, position_submenu ]]; +} + +#pragma mark - Window position drag mode (portrait, Fit only) + +- (void)resetWindowPosition { + XeniaIOSResetPortraitWindowOffset(); + [self.view setNeedsLayout]; + [self.view layoutIfNeeded]; +} + +- (void)enterWindowPositionMode { + if (self.windowPositionOverlay) { + return; + } + if (!self.gameRunning) { + return; + } + [self hideInGameMenuOverlay]; + + // Hide the touch overlay so its buttons don't compete for the pan gesture. + // Will be restored on exit. + if (self.touchControlsOverlay) { + [self.touchControlsOverlay setGameplayOverlayVisible:NO animated:NO]; + } + + XeniaIOSWindowPositionOverlay* overlay = + [[XeniaIOSWindowPositionOverlay alloc] initWithFrame:self.view.bounds]; + self.windowPositionOverlay = overlay; + [overlay release]; + + __unsafe_unretained XeniaViewController* unsafe_self = self; + [self.windowPositionOverlay beginInView:self.view + metalView:self.metalView + completion:^{ + unsafe_self.windowPositionOverlay = nil; + [unsafe_self updateTouchControlsOverlayVisibilityAnimated:YES]; + }]; +} + +- (void)exitWindowPositionMode { + if (!self.windowPositionOverlay) { + return; + } + [self.windowPositionOverlay endAnimated:NO]; +} + +- (void)openGameTapped:(UIButton*)sender { + (void)sender; + [self.documentImportCoordinator presentGameImportPicker]; +} + +- (void)openSettingsTapped:(UIButton*)sender { + (void)sender; + [self presentSettingsDestinationWithInitialSection:XeniaSettingsInitialSectionMain + preventInteractiveDismissal:YES]; +} + +- (void)presentationControllerDidDismiss:(UIPresentationController*)presentationController { + (void)presentationController; + gameplay_modal_presentation_pending_ = NO; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; +} + +#pragma mark - Document import coordinator host + +- (UIViewController*)documentImportCoordinatorPresenter { + return [self topPresentedControllerForModalPresentation] ?: self; +} + +- (BOOL)documentImportCoordinatorGameStopInProgress { + return self.gameStopInProgress; +} + +- (BOOL)documentImportCoordinatorJITAcquired { + return self.jitAcquired; +} + +- (void)documentImportCoordinatorSetStatusText:(NSString*)text { + if (text.length > 0) { + [self showStatusToastForMessage:text]; + } +} + +- (std::filesystem::path)documentImportCoordinatorImportGameAtURL:(NSURL*)sourceURL + error:(NSError**)error { + return [self importGameIntoLibrary:sourceURL error:error]; +} + +- (void)documentImportCoordinatorRefreshImportedGames { + [self refreshImportedGames]; +} + +- (void)documentImportCoordinatorLaunchGameAtPath:(const std::filesystem::path&)gamePath + displayName:(NSString*)displayName { + [self launchGameAtPath:gamePath displayName:displayName]; +} + +- (BOOL)documentImportCoordinatorCanInstallTitleUpdates { + return self.appContext != nullptr; +} + +- (BOOL)documentImportCoordinatorInstallTitleUpdateAtPath:(const std::filesystem::path&)path + status:(std::string*)statusOut + notTitleUpdate:(bool*)notTitleUpdateOut { + if (!self.appContext) { + if (statusOut) { + *statusOut = "Title update installation is unavailable."; + } + if (notTitleUpdateOut) { + *notTitleUpdateOut = false; + } + return NO; + } + return self.appContext->InstallTitleUpdate(path.string(), statusOut, notTitleUpdateOut); +} + +- (void)documentImportCoordinatorPresentAlertWithTitle:(NSString*)title message:(NSString*)message { + XEPresentOKAlert(self, title, message); +} + +- (void)documentImportCoordinatorImportTouchLayoutAtURL:(NSURL*)url { + [self.touchLayoutCoordinator importLayoutAtURL:url]; +} + +- (void)documentImportCoordinatorTouchLayoutImportCancelled { + if (gameplay_modal_presentation_pending_) { + gameplay_modal_presentation_pending_ = NO; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; + } +} + +#pragma mark - Status bar / home indicator + +- (BOOL)prefersStatusBarHidden { + return YES; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + // Always permit portrait and landscape, including during gameplay. + // The previous gameplay-only landscape lock conflicted with the window + // scaling modes; the portrait drag-to-position UX is meaningless if the user + // can never rotate the device to portrait after launching a game. + return UIInterfaceOrientationMaskAll; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return xe_current_interface_orientation(self.view); +} + +- (BOOL)shouldAutorotate { + return YES; +} + +- (BOOL)prefersHomeIndicatorAutoHidden { + return YES; +} + +- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { + return UIRectEdgeAll; +} + +#pragma mark - Public API + +- (void)showLauncherOverlay { + if (touch_layout_edit_mode_active_) { + [self saveTouchLayoutModelForTitleID:active_game_title_id_]; + [self setTouchLayoutEditModeActive:NO animated:NO]; + } + [self saveTouchLayoutModelForTitleID:active_game_title_id_]; + active_game_title_id_ = 0; + active_touch_layout_local_id_.clear(); + gameplay_modal_presentation_pending_ = NO; + self.gameRunning = NO; + self.gameStopInProgress = NO; + [self hideInGameMenuOverlay]; + self.launcherOverlayView.hidden = NO; + self.statusLabel.text = @""; + [self refreshSignedInProfileUI]; + [self updateJITStatusIndicator]; + [self updateJITAvailabilityUI]; + [self refreshImportedGamesAsync]; + [self.controllerNavigationCoordinator refreshLauncherFocus]; + xe_request_current_orientation(self); + [UIView animateWithDuration:0.3 + animations:^{ + self.launcherOverlayView.alpha = 1.0; + }]; + [self updateTouchControlsOverlayVisibilityAnimated:YES]; +} + +- (void)dealloc { + [self.jitPollTimer invalidate]; + [self.controllerNavigationCoordinator invalidate]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [compat_data_ release]; + [_controllerNavigationCoordinator release]; + [_documentImportCoordinator release]; + [_touchLayoutCoordinator release]; + [_windowPositionOverlay release]; + [_touchControlsOverlay release]; + [_achievementNotificationPresenter release]; + [_statusToastPresenter release]; + [super dealloc]; +} + +#pragma mark - Touch overlay state + +- (xe::hid::touch::IOSTouchRuntimeModel*)touchRuntimeModel { + return touch_runtime_model_.get(); +} + +- (uint32_t)titleIDForGamePath:(const std::filesystem::path&)game_path { + for (const IOSDiscoveredGame& game : discovered_games_) { + if (game.path == game_path && game.title_id) { + return game.title_id; + } + for (const auto& disc : game.discs) { + if (disc.path == game_path && game.title_id) { + return game.title_id; + } + } + } + IOSDiscoveredGame discovered_game; + if (BuildDiscoveredGameFromPath(game_path, &discovered_game)) { + return discovered_game.title_id; + } + return 0; +} + +- (BOOL)hasConnectedGameplayController { + for (GCController* controller in GCController.controllers) { + if (controller.extendedGamepad || controller.microGamepad) { + return YES; + } + } + return NO; +} + +- (BOOL)shouldBlockGameplayInput { + return self.gameRunning && + (touch_layout_edit_mode_active_ || gameplay_modal_presentation_pending_ || + !self.inGameMenuOverlay.hidden || self.presentedViewController != nil); +} + +- (BOOL)shouldShowTouchControlsOverlay { + if (touch_layout_edit_mode_active_) { + return self.gameRunning && self.launcherOverlayView.hidden && self.inGameMenuOverlay.hidden && + self.presentedViewController == nil; + } + + if (!cvars::ios_touch_overlay) { + return NO; + } + + return self.gameRunning && self.launcherOverlayView.hidden && self.inGameMenuOverlay.hidden && + self.presentedViewController == nil && !gameplay_modal_presentation_pending_ && + ![self hasConnectedGameplayController]; +} + +- (void)setTouchLayoutEditModeActive:(BOOL)active animated:(BOOL)animated { + touch_layout_edit_mode_active_ = active; + if (self.touchControlsOverlay) { + [self.touchControlsOverlay setEditingControlsEnabled:active animated:animated]; + } + [self updateTouchControlsOverlayVisibilityAnimated:animated]; +} + +- (void)finishTouchLayoutEditMode { + [self saveTouchLayoutModelForTitleID:active_game_title_id_]; + [self setTouchLayoutEditModeActive:NO animated:YES]; +} + +- (void)updateTouchControlsOverlayVisibilityAnimated:(BOOL)animated { + const BOOL overlay_visible = [self shouldShowTouchControlsOverlay]; + if (self.touchControlsOverlay) { + [self.touchControlsOverlay setGameplayOverlayVisible:overlay_visible animated:animated]; + } + if (self.appContext) { + self.appContext->SetGameplayInputBlocked([self shouldBlockGameplayInput] ? true : false); + } + // The screen-wide tap recognizer used to toggle the in-game menu would + // otherwise fire every time the user presses a touch overlay button (the + // recognizer's cancelsTouchesInView is NO so the button still fires too). + // While the overlay is the active input surface the user opens the menu via + // its dedicated pause button; the tap-anywhere fallback only matters when + // the overlay is hidden (e.g. hardware controller connected) or when the + // in-game menu is up and a tap-outside should dismiss it. + in_game_menu_tap_recognizer_.enabled = !overlay_visible; +} + +- (void)showStatusToast:(NSString*)message style:(XeniaIOSStatusToastStyle)style { + [self.statusToastPresenter presentMessage:message style:style inView:self.view]; +} + +- (void)showStatusToastForMessage:(NSString*)message { + if (message.length == 0) { + return; + } + NSString* lower = [message lowercaseString]; + XeniaIOSStatusToastStyle style = XeniaIOSStatusToastStyleInfo; + if ([lower rangeOfString:@"failed"].location != NSNotFound || + [lower rangeOfString:@"unable"].location != NSNotFound || + [lower rangeOfString:@"missing"].location != NSNotFound || + [lower rangeOfString:@"not found"].location != NSNotFound || + [lower rangeOfString:@"unavailable"].location != NSNotFound || + [lower rangeOfString:@"could not"].location != NSNotFound || + [lower rangeOfString:@"rejected"].location != NSNotFound) { + style = XeniaIOSStatusToastStyleError; + } else if ([lower rangeOfString:@"signed in"].location != NSNotFound || + [lower rangeOfString:@"copied"].location != NSNotFound || + [lower rangeOfString:@"imported"].location != NSNotFound || + [lower rangeOfString:@"installed"].location != NSNotFound || + [lower rangeOfString:@"applied"].location != NSNotFound || + [lower rangeOfString:@"renamed"].location != NSNotFound || + [lower rangeOfString:@"deleted"].location != NSNotFound || + [lower rangeOfString:@"saved copy"].location != NSNotFound || + [lower rangeOfString:@"restored"].location != NSNotFound || + [lower rangeOfString:@"sent"].location != NSNotFound) { + style = XeniaIOSStatusToastStyleSuccess; + } + [self showStatusToast:message style:style]; +} + +- (void)pauseButtonTapped:(id)__unused sender { + if (self.launcherOverlayView.hidden == NO || !self.gameRunning || self.presentedViewController) { + return; + } + + [self showInGameMenuOverlayAnimated:YES]; +} + +#pragma mark - Touch layout UI coordinator host + +- (UIViewController*)topPresentedControllerForModalPresentation { + UIViewController* presenter = self; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + return presenter; +} + +- (xe::hid::touch::IOSTouchRuntimeModel*)touchLayoutCoordinatorRuntimeModel { + return touch_runtime_model_.get(); +} + +- (uint32_t)touchLayoutCoordinatorActiveTitleID { + return active_game_title_id_; +} + +- (std::string)touchLayoutCoordinatorActiveLocalID { + return active_touch_layout_local_id_; +} + +- (void)touchLayoutCoordinatorSetActiveLocalID:(const std::string&)localID { + active_touch_layout_local_id_ = localID; +} + +- (BOOL)touchLayoutCoordinatorGameRunning { + return self.gameRunning; +} + +- (BOOL)touchLayoutCoordinatorCanPresentPendingInstall { + return self.view.window && + [UIApplication sharedApplication].applicationState == UIApplicationStateActive && + !self.presentedViewController; +} + +- (UIViewController*)touchLayoutCoordinatorTopPresenter { + return [self topPresentedControllerForModalPresentation]; +} + +- (void)touchLayoutCoordinatorSetGameplayModalPresentationPending:(BOOL)pending { + gameplay_modal_presentation_pending_ = pending; +} + +- (void)touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:(BOOL)animated { + [self updateTouchControlsOverlayVisibilityAnimated:animated]; +} + +- (void)touchLayoutCoordinatorRefreshTouchOverlayLayoutModel { + [self.touchControlsOverlay refreshLayoutModel]; +} + +- (BOOL)touchLayoutCoordinatorIsShowingLayoutLibrary { + return self.touchControlsOverlay && self.touchControlsOverlay.isShowingLayoutLibrary; +} + +- (void)touchLayoutCoordinatorShowLayoutLibraryWithItems: + (NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID { + [self.touchControlsOverlay showLayoutLibraryWithItems:items + currentLayoutLocalID:currentLayoutLocalID]; +} + +- (void)touchLayoutCoordinatorSetStatusText:(NSString*)text { + [self showStatusToastForMessage:text]; +} + +- (void)touchLayoutCoordinatorPresentAlertWithTitle:(NSString*)title message:(NSString*)message { + XEPresentOKAlert(self, title, message); +} + +- (void)touchLayoutCoordinatorPresentKeyboardPromptWithTitle:(NSString*)title + description:(NSString*)description + defaultText:(NSString*)defaultText + completion:(void (^)(BOOL cancelled, + NSString* text))completion { + [self presentSystemKeyboardPromptWithTitle:title + description:description + defaultText:defaultText + completion:completion]; +} + +- (void)touchLayoutCoordinatorOpenTouchLayoutFileImportPicker { + UIViewController* presenter = [self topPresentedControllerForModalPresentation]; + [self.documentImportCoordinator presentTouchLayoutImportPickerFromViewController:presenter]; +} + +- (void)touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded { + [self evaluateAutomaticStikDebugJITHandoffIfNeeded]; +} + +#pragma mark - Touch layout coordination + +- (void)applyDefaultTouchLayoutModel { + [self.touchLayoutCoordinator applyDefaultLayoutModel]; +} + +- (void)applyTouchLayoutModelForTitleID:(uint32_t)title_id { + [self.touchLayoutCoordinator applyLayoutModelForTitleID:title_id]; +} + +- (void)saveTouchLayoutModelForTitleID:(uint32_t)title_id { + [self.touchLayoutCoordinator saveCurrentLayoutForTitleID:title_id]; +} + +- (void)presentTouchLayoutLibrarySheet { + [self.touchLayoutCoordinator presentLibrary]; +} + +- (void)presentRenameTouchLayoutSheet { + [self.touchLayoutCoordinator presentRenameSheet]; +} + +- (void)presentDeleteTouchLayoutSheet { + [self.touchLayoutCoordinator presentDeleteSheet]; +} + +- (void)saveCurrentTouchLayoutCopy { + [self.touchLayoutCoordinator saveCurrentLayoutCopy]; +} + +- (void)importTouchLayoutFromFile { + [self.touchLayoutCoordinator importFromFile]; +} + +- (void)exportCurrentTouchLayout { + [self.touchLayoutCoordinator exportCurrentLayout]; +} + +- (void)resetToOfficialTouchLayoutPreset { + [self.touchLayoutCoordinator resetToOfficialPreset]; +} + +- (BOOL)handleExternalTouchLayoutFileURL:(NSURL*)url { + return [self.touchLayoutCoordinator handleExternalFileURL:url]; +} + +- (BOOL)handleExternalTouchLayoutSchemeURL:(NSURL*)url { + return [self.touchLayoutCoordinator handleExternalSchemeURL:url]; +} + +- (void)presentPendingTouchLayoutInstallIfReady { + [self.touchLayoutCoordinator presentPendingInstallIfReady]; +} + +@end diff --git a/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.h b/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.h new file mode 100644 index 000000000..4250fe598 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.h @@ -0,0 +1,61 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_PAUSE_DASHBOARD_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_APP_IOS_PAUSE_DASHBOARD_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Logical destinations the dashboard can ask its host to open. They map onto the +// existing in-game flows (touch editor, display options, quick/graphics +// settings, achievements sheet, live log) rather than owning that UI. +typedef NS_ENUM(NSInteger, XeniaPauseDashboardSection) { + XeniaPauseDashboardSectionControls = 0, + XeniaPauseDashboardSectionDisplay, + XeniaPauseDashboardSectionGraphics, + XeniaPauseDashboardSectionAchievements, + XeniaPauseDashboardSectionDiagnostics, +}; + +@class XeniaPauseDashboardViewController; + +@protocol XeniaPauseDashboardViewControllerDelegate +- (void)pauseDashboardDidSelectResume:(XeniaPauseDashboardViewController*)dashboard; +- (void)pauseDashboardDidSelectExit:(XeniaPauseDashboardViewController*)dashboard; +- (void)pauseDashboard:(XeniaPauseDashboardViewController*)dashboard + didSelectSection:(XeniaPauseDashboardSection)section; +@end + +// Unified in-game pause "home": a themed inset-grouped table that prioritizes +// Resume, surfaces the most-changed quick settings as value rows, summarizes the +// game, and treats Exit as a separate destructive action. Display strings are +// pushed in via properties and all actions are routed to the delegate, so the +// controller has no dependency on the app context and can be hosted directly or +// inside the iPad split-view workspace. Not yet wired into the overlay flow. +@interface XeniaPauseDashboardViewController : XESheetTableViewController + +@property(nonatomic, assign) id delegate; + +// Display strings, set by the host before presentation and on refresh. Nil +// values render as an empty detail (no crash). +@property(nonatomic, copy) NSString* gameTitle; +@property(nonatomic, copy) NSString* achievementsSummary; +@property(nonatomic, copy) NSString* profileName; +@property(nonatomic, copy) NSString* displayModeValue; +@property(nonatomic, copy) NSString* touchControlsValue; +@property(nonatomic, copy) NSString* performanceOverlayValue; + +// Re-applies the title and reloads rows from the current property values. +- (void)reloadDashboard; + +@end + +#endif // XENIA_UI_IOS_APP_IOS_PAUSE_DASHBOARD_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.mm b/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.mm new file mode 100644 index 000000000..4fae14171 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_pause_dashboard_view_controller.mm @@ -0,0 +1,308 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_pause_dashboard_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +typedef NS_ENUM(NSInteger, XeniaPauseDashboardRowId) { + XeniaPauseDashboardRowResume = 0, + XeniaPauseDashboardRowDisplayMode, + XeniaPauseDashboardRowTouchControls, + XeniaPauseDashboardRowPerformanceOverlay, + XeniaPauseDashboardRowAchievements, + XeniaPauseDashboardRowProfile, + XeniaPauseDashboardRowGraphics, + XeniaPauseDashboardRowDiagnostics, + XeniaPauseDashboardRowExit, +}; + +@implementation XeniaPauseDashboardViewController { + NSArray* sections_; + NSString* game_title_; + NSString* achievements_summary_; + NSString* profile_name_; + NSString* display_mode_value_; + NSString* touch_controls_value_; + NSString* performance_overlay_value_; + id delegate_; // assign (no retain) +} + +@synthesize delegate = delegate_; +@synthesize gameTitle = game_title_; +@synthesize achievementsSummary = achievements_summary_; +@synthesize profileName = profile_name_; +@synthesize displayModeValue = display_mode_value_; +@synthesize touchControlsValue = touch_controls_value_; +@synthesize performanceOverlayValue = performance_overlay_value_; + +- (instancetype)init { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + [self buildSections]; + } + return self; +} + +- (void)dealloc { + [sections_ release]; + [game_title_ release]; + [achievements_summary_ release]; + [profile_name_ release]; + [display_mode_value_ release]; + [touch_controls_value_ release]; + [performance_overlay_value_ release]; + [super dealloc]; +} + +- (void)buildSections { + // Static layout. Quick rows are shortcuts into the matching destination, so + // there is one canonical entry point per area (Display via Display Mode, + // Controls via Touch Controls, Achievements in Game, Graphics/Diagnostics in + // More) — no duplicated category like the old grid had. + NSArray* sections = @[ + @{@"rows" : @[ @(XeniaPauseDashboardRowResume) ]}, + @{ + @"header" : @"Quick Settings", + @"rows" : @[ + @(XeniaPauseDashboardRowDisplayMode), + @(XeniaPauseDashboardRowTouchControls), + @(XeniaPauseDashboardRowPerformanceOverlay), + ] + }, + @{ + @"header" : @"Game", + @"rows" : @[ @(XeniaPauseDashboardRowAchievements), @(XeniaPauseDashboardRowProfile) ] + }, + @{ + @"header" : @"More", + @"rows" : @[ @(XeniaPauseDashboardRowGraphics), @(XeniaPauseDashboardRowDiagnostics) ] + }, + @{@"rows" : @[ @(XeniaPauseDashboardRowExit) ]}, + ]; + [sections_ release]; + sections_ = [sections retain]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = game_title_.length ? game_title_ : @"Game Menu"; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 52.0; + if (@available(iOS 15.0, *)) { + self.tableView.sectionHeaderTopPadding = 0; + } +} + +- (void)reloadDashboard { + self.title = game_title_.length ? game_title_ : @"Game Menu"; + [self.tableView reloadData]; +} + +- (XeniaPauseDashboardRowId)rowIdForIndexPath:(NSIndexPath*)indexPath { + NSArray* rows = sections_[(NSUInteger)indexPath.section][@"rows"]; + return (XeniaPauseDashboardRowId)rows[(NSUInteger)indexPath.row].integerValue; +} + +#pragma mark - Cell configuration + +- (void)configureValueCell:(UITableViewCell*)cell + title:(NSString*)title + symbol:(NSString*)symbol + value:(NSString*)value + disclosure:(BOOL)disclosure + selectable:(BOOL)selectable { + cell.textLabel.text = title; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + cell.detailTextLabel.text = value; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.detailTextLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + cell.imageView.image = symbol.length ? [UIImage systemImageNamed:symbol] : nil; + cell.imageView.tintColor = [XeniaTheme accent]; + cell.accessoryType = + disclosure ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; + cell.selectionStyle = + selectable ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = title; + cell.accessibilityValue = value; + cell.accessibilityTraits = + selectable ? UIAccessibilityTraitButton : UIAccessibilityTraitStaticText; +} + +- (void)configureActionCell:(UITableViewCell*)cell + title:(NSString*)title + symbol:(NSString*)symbol + accentFill:(BOOL)accentFill + destructive:(BOOL)destructive { + cell.textLabel.text = title; + cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + cell.imageView.image = symbol.length ? [UIImage systemImageNamed:symbol] : nil; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + + UIBackgroundConfiguration* background = + [UIBackgroundConfiguration listGroupedCellConfiguration]; + if (accentFill) { + background.backgroundColor = [XeniaTheme accent]; + cell.textLabel.textColor = [XeniaTheme accentFg]; + cell.imageView.tintColor = [XeniaTheme accentFg]; + } else { + UIColor* tint = destructive ? [XeniaTheme statusError] : [XeniaTheme textPrimary]; + cell.textLabel.textColor = tint; + cell.imageView.tintColor = tint; + } + cell.backgroundConfiguration = background; + + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = title; + cell.accessibilityTraits = UIAccessibilityTraitButton; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView { + return (NSInteger)sections_.count; +} + +- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section { + NSArray* rows = sections_[(NSUInteger)section][@"rows"]; + return (NSInteger)rows.count; +} + +- (NSString*)tableView:(UITableView*)tableView titleForHeaderInSection:(NSInteger)section { + return sections_[(NSUInteger)section][@"header"]; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + const XeniaPauseDashboardRowId row = [self rowIdForIndexPath:indexPath]; + const BOOL is_action = + (row == XeniaPauseDashboardRowResume || row == XeniaPauseDashboardRowExit); + NSString* const reuse_id = is_action ? @"XeniaPauseActionCell" : @"XeniaPauseValueCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:reuse_id]; + if (!cell) { + UITableViewCellStyle style = + is_action ? UITableViewCellStyleDefault : UITableViewCellStyleValue1; + cell = [[[UITableViewCell alloc] initWithStyle:style + reuseIdentifier:reuse_id] autorelease]; + } + + switch (row) { + case XeniaPauseDashboardRowResume: + [self configureActionCell:cell + title:@"Resume Game" + symbol:@"play.fill" + accentFill:YES + destructive:NO]; + break; + case XeniaPauseDashboardRowExit: + [self configureActionCell:cell + title:@"Exit to Library" + symbol:@"rectangle.portrait.and.arrow.right" + accentFill:NO + destructive:YES]; + break; + case XeniaPauseDashboardRowDisplayMode: + [self configureValueCell:cell + title:@"Display Mode" + symbol:@"rectangle.expand.vertical" + value:display_mode_value_ + disclosure:YES + selectable:YES]; + break; + case XeniaPauseDashboardRowTouchControls: + [self configureValueCell:cell + title:@"Touch Controls" + symbol:@"hand.tap" + value:touch_controls_value_ + disclosure:YES + selectable:YES]; + break; + case XeniaPauseDashboardRowPerformanceOverlay: + [self configureValueCell:cell + title:@"Performance Overlay" + symbol:@"speedometer" + value:performance_overlay_value_ + disclosure:YES + selectable:YES]; + break; + case XeniaPauseDashboardRowAchievements: + [self configureValueCell:cell + title:@"Achievements" + symbol:@"trophy" + value:achievements_summary_ + disclosure:YES + selectable:YES]; + break; + case XeniaPauseDashboardRowProfile: + [self configureValueCell:cell + title:@"Profile" + symbol:@"person.crop.circle" + value:profile_name_ + disclosure:NO + selectable:NO]; + break; + case XeniaPauseDashboardRowGraphics: + [self configureValueCell:cell + title:@"Graphics" + symbol:@"slider.horizontal.3" + value:nil + disclosure:YES + selectable:YES]; + break; + case XeniaPauseDashboardRowDiagnostics: + [self configureValueCell:cell + title:@"Diagnostics" + symbol:@"waveform.path.ecg" + value:nil + disclosure:YES + selectable:YES]; + break; + } + return cell; +} + +#pragma mark - UITableViewDelegate + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + switch ([self rowIdForIndexPath:indexPath]) { + case XeniaPauseDashboardRowResume: + [delegate_ pauseDashboardDidSelectResume:self]; + break; + case XeniaPauseDashboardRowExit: + [delegate_ pauseDashboardDidSelectExit:self]; + break; + case XeniaPauseDashboardRowDisplayMode: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionDisplay]; + break; + case XeniaPauseDashboardRowTouchControls: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionControls]; + break; + case XeniaPauseDashboardRowPerformanceOverlay: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionDiagnostics]; + break; + case XeniaPauseDashboardRowAchievements: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionAchievements]; + break; + case XeniaPauseDashboardRowGraphics: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionGraphics]; + break; + case XeniaPauseDashboardRowDiagnostics: + [delegate_ pauseDashboard:self didSelectSection:XeniaPauseDashboardSectionDiagnostics]; + break; + case XeniaPauseDashboardRowProfile: + break; + } +} + +@end diff --git a/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.h b/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.h new file mode 100644 index 000000000..9ebfbc7b2 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.h @@ -0,0 +1,45 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_PAUSE_WORKSPACE_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_APP_IOS_PAUSE_WORKSPACE_VIEW_CONTROLLER_H_ + +#import + +#import "xenia/ui/ios/app/ios_pause_dashboard_view_controller.h" + +@class XeniaPauseWorkspaceViewController; + +@protocol XeniaPauseWorkspaceViewControllerDelegate +- (void)pauseWorkspaceDidSelectResume:(XeniaPauseWorkspaceViewController*)workspace; +- (void)pauseWorkspaceDidSelectExit:(XeniaPauseWorkspaceViewController*)workspace; +// Vends the detail controller for a section. The host owns how each section is +// built (achievements sheet, quick/graphics settings, live log, …). Return nil +// to ignore the selection. +- (UIViewController*)pauseWorkspace:(XeniaPauseWorkspaceViewController*)workspace + contentForSection:(XeniaPauseDashboardSection)section; +@end + +// iPad / regular-width host for the pause dashboard: a double-column split view +// with the dashboard as the sidebar and the selected section in the detail +// column. On compact width UIKit collapses it to a single navigation stack +// rooted at the dashboard, so iPhone (incl. landscape) stays a stack rather than +// a cramped sidebar — the same controllers serve both layouts. Section content +// is vended by the delegate so the workspace stays decoupled from the app +// context. Not yet wired into the overlay flow. +@interface XeniaPauseWorkspaceViewController : UISplitViewController + +- (instancetype)initWithDashboard:(XeniaPauseDashboardViewController*)dashboard; + +@property(nonatomic, assign) id workspaceDelegate; +@property(nonatomic, readonly) XeniaPauseDashboardViewController* dashboard; + +@end + +#endif // XENIA_UI_IOS_APP_IOS_PAUSE_WORKSPACE_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.mm b/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.mm new file mode 100644 index 000000000..6c77180c8 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_pause_workspace_view_controller.mm @@ -0,0 +1,113 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_pause_workspace_view_controller.h" + +#import "xenia/ui/ios/app/ios_landscape_navigation_controller.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@interface XeniaPauseWorkspaceViewController () +@end + +@implementation XeniaPauseWorkspaceViewController { + XeniaPauseDashboardViewController* dashboard_; + XeniaLandscapeNavigationController* primary_nav_; + XeniaLandscapeNavigationController* secondary_nav_; + id workspace_delegate_; // assign +} + +@synthesize workspaceDelegate = workspace_delegate_; + +- (instancetype)initWithDashboard:(XeniaPauseDashboardViewController*)dashboard { + self = [super initWithStyle:UISplitViewControllerStyleDoubleColumn]; + if (self) { + dashboard_ = [dashboard retain]; + dashboard_.delegate = self; + + primary_nav_ = + [[XeniaLandscapeNavigationController alloc] initWithRootViewController:dashboard_]; + secondary_nav_ = [[XeniaLandscapeNavigationController alloc] + initWithRootViewController:[self makePlaceholderController]]; + + self.delegate = self; + self.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; + self.preferredSplitBehavior = UISplitViewControllerSplitBehaviorTile; + self.preferredPrimaryColumnWidthFraction = 0.36; + self.minimumPrimaryColumnWidth = 240.0; + self.maximumPrimaryColumnWidth = 360.0; + [self setViewController:primary_nav_ forColumn:UISplitViewControllerColumnPrimary]; + [self setViewController:secondary_nav_ forColumn:UISplitViewControllerColumnSecondary]; + } + return self; +} + +- (void)dealloc { + [dashboard_ release]; + [primary_nav_ release]; + [secondary_nav_ release]; + [super dealloc]; +} + +- (XeniaPauseDashboardViewController*)dashboard { + return dashboard_; +} + +- (UIViewController*)makePlaceholderController { + UIViewController* placeholder = [[[UIViewController alloc] init] autorelease]; + placeholder.view.backgroundColor = [UIColor systemBackgroundColor]; + UILabel* hint = [[[UILabel alloc] init] autorelease]; + hint.translatesAutoresizingMaskIntoConstraints = NO; + hint.text = @"Select an option"; + hint.textColor = [XeniaTheme textMuted]; + hint.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + [placeholder.view addSubview:hint]; + [NSLayoutConstraint activateConstraints:@[ + [hint.centerXAnchor constraintEqualToAnchor:placeholder.view.centerXAnchor], + [hint.centerYAnchor constraintEqualToAnchor:placeholder.view.centerYAnchor], + ]]; + return placeholder; +} + +#pragma mark - XeniaPauseDashboardViewControllerDelegate + +- (void)pauseDashboardDidSelectResume:(XeniaPauseDashboardViewController*)dashboard { + [workspace_delegate_ pauseWorkspaceDidSelectResume:self]; +} + +- (void)pauseDashboardDidSelectExit:(XeniaPauseDashboardViewController*)dashboard { + [workspace_delegate_ pauseWorkspaceDidSelectExit:self]; +} + +- (void)pauseDashboard:(XeniaPauseDashboardViewController*)dashboard + didSelectSection:(XeniaPauseDashboardSection)section { + UIViewController* content = [workspace_delegate_ pauseWorkspace:self contentForSection:section]; + if (!content) { + return; + } + if (self.isCollapsed) { + [primary_nav_ pushViewController:content animated:YES]; + } else { + XeniaLandscapeNavigationController* nav = [[[XeniaLandscapeNavigationController alloc] + initWithRootViewController:content] autorelease]; + [self setViewController:nav forColumn:UISplitViewControllerColumnSecondary]; + [self showColumn:UISplitViewControllerColumnSecondary]; + } +} + +#pragma mark - UISplitViewControllerDelegate + +- (UISplitViewControllerColumn)splitViewController:(UISplitViewController*)svc + topColumnForCollapsingToProposedTopColumn:(UISplitViewControllerColumn)proposedTopColumn { + // Collapse to the dashboard (sidebar) rather than the detail column so the + // compact presentation is a single nav stack rooted at the dashboard. + return UISplitViewControllerColumnPrimary; +} + +@end diff --git a/src/xenia/ui/ios/app/ios_window_layout.h b/src/xenia/ui/ios/app/ios_window_layout.h new file mode 100644 index 000000000..49dcd89a0 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_window_layout.h @@ -0,0 +1,35 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_WINDOW_LAYOUT_H_ +#define XENIA_UI_IOS_WINDOW_LAYOUT_H_ + +#import + +// User-selectable mode for how the aspect-aware presenter surface is fit into +// the device screen. Persisted in NSUserDefaults by the helper functions below. +typedef NS_ENUM(NSInteger, XeniaIOSWindowScalingMode) { + XeniaIOSWindowScalingModeFit = 0, + XeniaIOSWindowScalingModeStretch = 1, + XeniaIOSWindowScalingModeZoom = 2, +}; + +XeniaIOSWindowScalingMode XeniaIOSCurrentWindowScalingMode(void); +void XeniaIOSSetCurrentWindowScalingMode(XeniaIOSWindowScalingMode mode); + +CGPoint XeniaIOSCurrentPortraitWindowOffset(void); +void XeniaIOSSetPortraitWindowOffset(CGPoint offset); +void XeniaIOSResetPortraitWindowOffset(void); + +CGRect XeniaIOSMetalViewFrameForParent(CGRect parent, CGFloat guest_aspect, + XeniaIOSWindowScalingMode mode, CGPoint portrait_offset); +CGPoint XeniaIOSPortraitWindowOffsetByApplyingDrag(CGPoint current_offset, CGSize slack, + CGPoint translation); + +#endif // XENIA_UI_IOS_WINDOW_LAYOUT_H_ diff --git a/src/xenia/ui/ios/app/ios_window_layout.mm b/src/xenia/ui/ios/app/ios_window_layout.mm new file mode 100644 index 000000000..fa33f4fb0 --- /dev/null +++ b/src/xenia/ui/ios/app/ios_window_layout.mm @@ -0,0 +1,121 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_window_layout.h" + +#include + +namespace { + +NSString* const kXeniaIOSWindowScalingModeKey = @"XeniaWindowScalingMode"; +NSString* const kXeniaIOSPortraitOffsetXKey = @"XeniaPortraitWindowOffsetX"; +NSString* const kXeniaIOSPortraitOffsetYKey = @"XeniaPortraitWindowOffsetY"; + +CGFloat ClampNormalizedOffset(CGFloat value) { + return std::clamp(value, -1.0, 1.0); +} + +} // namespace + +XeniaIOSWindowScalingMode XeniaIOSCurrentWindowScalingMode(void) { + // NSUserDefaults integerForKey: returns 0 if unset, which maps to Fit. + NSInteger raw = + [[NSUserDefaults standardUserDefaults] integerForKey:kXeniaIOSWindowScalingModeKey]; + if (raw < XeniaIOSWindowScalingModeFit || raw > XeniaIOSWindowScalingModeZoom) { + return XeniaIOSWindowScalingModeFit; + } + return static_cast(raw); +} + +void XeniaIOSSetCurrentWindowScalingMode(XeniaIOSWindowScalingMode mode) { + [[NSUserDefaults standardUserDefaults] setInteger:mode forKey:kXeniaIOSWindowScalingModeKey]; +} + +CGPoint XeniaIOSCurrentPortraitWindowOffset(void) { + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + return CGPointMake(ClampNormalizedOffset(static_cast( + [defaults doubleForKey:kXeniaIOSPortraitOffsetXKey])), + ClampNormalizedOffset(static_cast( + [defaults doubleForKey:kXeniaIOSPortraitOffsetYKey]))); +} + +void XeniaIOSSetPortraitWindowOffset(CGPoint offset) { + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + [defaults setDouble:ClampNormalizedOffset(offset.x) forKey:kXeniaIOSPortraitOffsetXKey]; + [defaults setDouble:ClampNormalizedOffset(offset.y) forKey:kXeniaIOSPortraitOffsetYKey]; +} + +void XeniaIOSResetPortraitWindowOffset(void) { + XeniaIOSSetPortraitWindowOffset(CGPointZero); +} + +CGRect XeniaIOSMetalViewFrameForParent(CGRect parent, CGFloat guest_aspect, + XeniaIOSWindowScalingMode mode, + CGPoint portrait_offset) { + if (CGRectIsEmpty(parent)) { + return CGRectZero; + } + + const CGFloat parent_aspect = parent.size.width / MAX(parent.size.height, 1.0); + const BOOL is_portrait = parent.size.height >= parent.size.width; + CGRect frame = parent; + switch (mode) { + case XeniaIOSWindowScalingModeStretch: + frame = parent; + break; + case XeniaIOSWindowScalingModeZoom: { + if (parent_aspect > guest_aspect) { + frame.size.width = parent.size.width; + frame.size.height = parent.size.width / guest_aspect; + } else { + frame.size.height = parent.size.height; + frame.size.width = parent.size.height * guest_aspect; + } + frame.origin.x = parent.origin.x + (parent.size.width - frame.size.width) * 0.5; + frame.origin.y = parent.origin.y + (parent.size.height - frame.size.height) * 0.5; + break; + } + case XeniaIOSWindowScalingModeFit: + default: { + if (parent_aspect > guest_aspect) { + frame.size.height = parent.size.height; + frame.size.width = parent.size.height * guest_aspect; + } else { + frame.size.width = parent.size.width; + frame.size.height = parent.size.width / guest_aspect; + } + frame.origin.x = parent.origin.x + (parent.size.width - frame.size.width) * 0.5; + frame.origin.y = parent.origin.y + (parent.size.height - frame.size.height) * 0.5; + break; + } + } + + if (is_portrait && mode == XeniaIOSWindowScalingModeFit) { + const CGFloat slack_x = MAX(parent.size.width - frame.size.width, 0.0); + const CGFloat slack_y = MAX(parent.size.height - frame.size.height, 0.0); + frame.origin.x += slack_x * 0.5 * ClampNormalizedOffset(portrait_offset.x); + frame.origin.y += slack_y * 0.5 * ClampNormalizedOffset(portrait_offset.y); + } + + return frame; +} + +CGPoint XeniaIOSPortraitWindowOffsetByApplyingDrag(CGPoint current_offset, CGSize slack, + CGPoint translation) { + CGPoint next_offset = current_offset; + if (slack.width > 0.0) { + next_offset.x = + ClampNormalizedOffset(next_offset.x + (translation.x * 2.0 / slack.width)); + } + if (slack.height > 0.0) { + next_offset.y = + ClampNormalizedOffset(next_offset.y + (translation.y * 2.0 / slack.height)); + } + return next_offset; +} diff --git a/src/xenia/ui/ios/app/ios_window_position_overlay.h b/src/xenia/ui/ios/app/ios_window_position_overlay.h new file mode 100644 index 000000000..2b018d9fb --- /dev/null +++ b/src/xenia/ui/ios/app/ios_window_position_overlay.h @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_APP_IOS_WINDOW_POSITION_OVERLAY_H_ +#define XENIA_UI_IOS_APP_IOS_WINDOW_POSITION_OVERLAY_H_ + +#ifdef __OBJC__ + +#import + +@interface XeniaIOSWindowPositionOverlay : UIView + +- (void)beginInView:(UIView*)view + metalView:(UIView*)metalView + completion:(void (^)(void))completion; +- (void)endAnimated:(BOOL)animated; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_APP_IOS_WINDOW_POSITION_OVERLAY_H_ diff --git a/src/xenia/ui/ios/app/ios_window_position_overlay.mm b/src/xenia/ui/ios/app/ios_window_position_overlay.mm new file mode 100644 index 000000000..0763d058e --- /dev/null +++ b/src/xenia/ui/ios/app/ios_window_position_overlay.mm @@ -0,0 +1,153 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/app/ios_window_position_overlay.h" + +#import "xenia/ui/ios/app/ios_window_layout.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaIOSWindowPositionOverlay { + UILabel* _hintLabel; + UIButton* _doneButton; + UIPanGestureRecognizer* _panGesture; + UIView* _metalView; + void (^_completion)(void); +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (!(self = [super initWithFrame:frame])) { + return nil; + } + + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.20]; + + _hintLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _hintLabel.text = @"Drag anywhere to reposition the game."; + _hintLabel.textColor = [UIColor whiteColor]; + _hintLabel.font = [UIFont systemFontOfSize:13.0 weight:UIFontWeightMedium]; + _hintLabel.backgroundColor = [UIColor clearColor]; + [self addSubview:_hintLabel]; + + UIButtonConfiguration* done_config = [UIButtonConfiguration filledButtonConfiguration]; + done_config.title = @"Done"; + done_config.baseBackgroundColor = [XeniaTheme accent]; + done_config.baseForegroundColor = [XeniaTheme accentFg]; + done_config.cornerStyle = UIButtonConfigurationCornerStyleLarge; + _doneButton = [[UIButton buttonWithConfiguration:done_config primaryAction:nil] retain]; + [_doneButton addTarget:self + action:@selector(donePressed:) + forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_doneButton]; + + _panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self + action:@selector(panRecognized:)]; + [self addGestureRecognizer:_panGesture]; + + return self; +} + +- (void)dealloc { + [_completion release]; + [_panGesture release]; + [_hintLabel release]; + [_doneButton release]; + [super dealloc]; +} + +- (void)beginInView:(UIView*)view + metalView:(UIView*)metalView + completion:(void (^)(void))completion { + if (self.superview || !view) { + return; + } + _metalView = metalView; + [_completion release]; + _completion = [completion copy]; + self.frame = view.bounds; + self.alpha = 1.0; + [view addSubview:self]; + [self setNeedsLayout]; +} + +- (void)finishEnd { + [_panGesture setEnabled:NO]; + [self removeFromSuperview]; + _metalView = nil; + void (^completion)(void) = [_completion copy]; + [_completion release]; + _completion = nil; + if (completion) { + completion(); + [completion release]; + } +} + +- (void)endAnimated:(BOOL)animated { + if (!self.superview) { + return; + } + + [self retain]; + if (animated) { + [UIView animateWithDuration:0.15 + animations:^{ + self.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + [self finishEnd]; + [self release]; + }]; + return; + } + + [self finishEnd]; + [self release]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + const CGRect bounds = self.bounds; + const CGFloat top = self.safeAreaInsets.top + 12.0; + const CGFloat right = bounds.size.width - self.safeAreaInsets.right - 12.0; + const CGSize done_size = CGSizeMake(96.0, 36.0); + _doneButton.frame = CGRectMake(right - done_size.width, top, done_size.width, done_size.height); + _hintLabel.frame = + CGRectMake(self.safeAreaInsets.left + 16.0, top + 4.0, + right - done_size.width - self.safeAreaInsets.left - 32.0, 28.0); +} + +- (void)donePressed:(UIButton*)__unused sender { + [self endAnimated:NO]; +} + +- (void)panRecognized:(UIPanGestureRecognizer*)pan { + if (!_metalView || !_metalView.superview) { + return; + } + + const CGRect parent = self.bounds; + const CGRect frame = _metalView.frame; + const CGFloat slack_x = MAX(parent.size.width - frame.size.width, 0.0); + const CGFloat slack_y = MAX(parent.size.height - frame.size.height, 0.0); + if (slack_x <= 0.0 && slack_y <= 0.0) { + return; + } + + CGPoint translation = [pan translationInView:self]; + [pan setTranslation:CGPointZero inView:self]; + XeniaIOSSetPortraitWindowOffset(XeniaIOSPortraitWindowOffsetByApplyingDrag( + XeniaIOSCurrentPortraitWindowOffset(), CGSizeMake(slack_x, slack_y), translation)); + UIView* host_view = self.superview; + [host_view setNeedsLayout]; + [host_view layoutIfNeeded]; +} + +@end diff --git a/src/xenia/ui/ios/app/windowed_app_context_ios.h b/src/xenia/ui/ios/app/windowed_app_context_ios.h new file mode 100644 index 000000000..a540f1afe --- /dev/null +++ b/src/xenia/ui/ios/app/windowed_app_context_ios.h @@ -0,0 +1,441 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_WINDOWED_APP_CONTEXT_IOS_H_ +#define XENIA_UI_WINDOWED_APP_CONTEXT_IOS_H_ + +#include +#include +#include +#include +#include +#include + +#include "xenia/ui/achievement_notification_payload.h" +#include "xenia/ui/windowed_app_context.h" + +#ifdef __OBJC__ +@class UIView; +@class UIViewController; +#else +typedef struct objc_object UIView; +typedef struct objc_object UIViewController; +#endif + +namespace xe { +namespace hid { +struct X_INPUT_STATE; +namespace touch { +class IOSTouchRuntimeModel; +} // namespace touch +} // namespace hid +namespace ui { + +enum class IOSGameSystem : uint8_t { + kXbox360 = 0, +}; + +struct IOSProfileSummary { + uint64_t xuid = 0; + std::string gamertag; + bool signed_in = false; + uint8_t signed_in_slot = 0xFF; +}; + +struct IOSAchievementEntry { + uint32_t achievement_id = 0; + uint32_t gamerscore = 0; + uint32_t flags = 0; + bool unlocked = false; + bool unlocked_online = false; + bool show_unachieved = false; + std::string title; + std::string unlocked_description; + std::string locked_description; + std::vector icon_data; +}; + +struct IOSAchievementsSnapshot { + uint32_t title_id = 0; + std::string title_name; + uint32_t achievements_total = 0; + uint32_t achievements_unlocked = 0; + uint32_t gamerscore_total = 0; + uint32_t gamerscore_earned = 0; + std::vector achievements; +}; + +struct IOSPatchEntrySummary { + size_t patch_index = 0; + std::string name; + std::string description; + std::string author; + bool is_enabled = false; +}; + +struct IOSPatchFileSummary { + uint32_t title_id = 0; + std::string filename; + std::string display_name; + std::string title_name; + std::vector patches; +}; + +struct IOSPatchDiscoverySummary { + std::string directory_path; + bool directory_exists = false; + size_t bundled_files = 0; + size_t scanned_files = 0; + size_t candidate_files = 0; + size_t matching_files = 0; + size_t parse_failures = 0; + size_t title_mismatches = 0; +}; + +class IOSWindowedAppContext final : public WindowedAppContext { + public: + IOSWindowedAppContext(); + ~IOSWindowedAppContext(); + + void NotifyUILoopOfPendingFunctions() override; + void PlatformQuitFromUIThread() override; + + // The Metal-backed rendering view, set by the app delegate after UIKit + // hierarchy creation. + UIView* metal_view() const { return metal_view_; } + void set_metal_view(UIView* view) { metal_view_ = view; } + + UIViewController* view_controller() const { return view_controller_; } + void set_view_controller(UIViewController* vc) { view_controller_ = vc; } + + // Callback invoked when the user selects a game file to launch. + using GameLaunchCallback = std::function; + void set_game_launch_callback(GameLaunchCallback callback) { + game_launch_callback_ = std::move(callback); + } + void LaunchGame(const std::string& path) { + if (game_launch_callback_) { + game_launch_callback_(path); + } + } + + using ProfilesListCallback = std::function()>; + using ProfileCreateCallback = std::function; + using ProfileSignInCallback = std::function; + using GameTerminateCallback = std::function; + using TitleUpdateInstallCallback = std::function; + using GameExitedCallback = std::function; + using ProfileServicesReadyCallback = std::function; + using SignInUIPromptCallback = std::function; + using AchievementsSnapshotCallback = std::function; + using AchievementsUIPromptCallback = std::function; + using AchievementNotificationCallback = + std::function; + using PatchFilesListCallback = std::function(uint32_t)>; + using PatchDiscoverySummaryCallback = std::function; + using PatchSetEnabledCallback = + std::function; + using ControllerStateCallback = std::function; + using MessageBoxPromptCallback = + std::function&, + uint32_t, uint32_t*)>; + using KeyboardPromptCallback = std::function; + using GameplayInputBlockedCallback = std::function; + using GuestDisplayAspectRatioCallback = std::function()>; + using GuestDisplayRefreshCapGetter = std::function; + using GuestDisplayRefreshCapSetter = std::function; + using PresentLetterboxGetter = std::function; + using PresentLetterboxSetter = std::function; + + void set_profiles_list_callback(ProfilesListCallback callback) { + profiles_list_callback_ = std::move(callback); + } + std::vector ListProfiles() const { + if (!profiles_list_callback_) { + return {}; + } + return profiles_list_callback_(); + } + + void set_profile_create_callback(ProfileCreateCallback callback) { + profile_create_callback_ = std::move(callback); + } + uint64_t CreateProfile(const std::string& gamertag) const { + if (!profile_create_callback_) { + return 0; + } + return profile_create_callback_(gamertag); + } + + void set_profile_sign_in_callback(ProfileSignInCallback callback) { + profile_sign_in_callback_ = std::move(callback); + } + bool SignInProfile(uint64_t xuid) const { + if (!profile_sign_in_callback_) { + return false; + } + return profile_sign_in_callback_(xuid); + } + + void set_profile_services_ready_callback(ProfileServicesReadyCallback callback) { + profile_services_ready_callback_ = std::move(callback); + } + void NotifyProfileServicesReady() const { + if (profile_services_ready_callback_) { + profile_services_ready_callback_(); + } + } + + void set_game_terminate_callback(GameTerminateCallback callback) { + game_terminate_callback_ = std::move(callback); + } + bool TerminateCurrentGame() const { + if (!game_terminate_callback_) { + return false; + } + return game_terminate_callback_(); + } + + void set_title_update_install_callback(TitleUpdateInstallCallback callback) { + title_update_install_callback_ = std::move(callback); + } + bool InstallTitleUpdate(const std::string& path, std::string* status_out, + bool* not_title_update_out) const { + if (not_title_update_out) { + *not_title_update_out = false; + } + if (!title_update_install_callback_) { + if (status_out) { + *status_out = "Title update installation is unavailable."; + } + return false; + } + return title_update_install_callback_(path, status_out, not_title_update_out); + } + + void set_game_exited_callback(GameExitedCallback callback) { + game_exited_callback_ = std::move(callback); + } + void NotifyGameExited() { + if (game_exited_callback_) { + game_exited_callback_(); + } + } + + void set_signin_ui_prompt_callback(SignInUIPromptCallback callback) { + signin_ui_prompt_callback_ = std::move(callback); + } + bool PromptSignInUI(uint32_t user_index, uint32_t users_needed) const { + if (!signin_ui_prompt_callback_) { + return false; + } + return signin_ui_prompt_callback_(user_index, users_needed); + } + + void set_achievements_snapshot_callback(AchievementsSnapshotCallback callback) { + achievements_snapshot_callback_ = std::move(callback); + } + IOSAchievementsSnapshot LoadAchievementsSnapshot(uint32_t user_index, uint32_t title_id) const { + if (!achievements_snapshot_callback_) { + return {}; + } + return achievements_snapshot_callback_(user_index, title_id); + } + + void set_achievements_ui_prompt_callback(AchievementsUIPromptCallback callback) { + achievements_ui_prompt_callback_ = std::move(callback); + } + bool PromptAchievementsUI(uint32_t user_index, uint32_t title_id) const { + if (!achievements_ui_prompt_callback_) { + return false; + } + return achievements_ui_prompt_callback_(user_index, title_id); + } + + void set_achievement_notification_callback(AchievementNotificationCallback callback) { + achievement_notification_callback_ = std::move(callback); + } + bool PresentAchievementNotification(const AchievementNotificationPayload& payload) const { + if (!achievement_notification_callback_) { + return false; + } + return achievement_notification_callback_(payload); + } + + void set_patch_files_list_callback(PatchFilesListCallback callback) { + patch_files_list_callback_ = std::move(callback); + } + std::vector ListPatchFiles(uint32_t title_id) const { + if (!patch_files_list_callback_) { + return {}; + } + return patch_files_list_callback_(title_id); + } + + void set_patch_discovery_summary_callback(PatchDiscoverySummaryCallback callback) { + patch_discovery_summary_callback_ = std::move(callback); + } + IOSPatchDiscoverySummary GetPatchDiscoverySummary(uint32_t title_id) const { + if (!patch_discovery_summary_callback_) { + return {}; + } + return patch_discovery_summary_callback_(title_id); + } + + void set_patch_set_enabled_callback(PatchSetEnabledCallback callback) { + patch_set_enabled_callback_ = std::move(callback); + } + bool SetPatchEnabled(uint32_t title_id, const std::string& filename, size_t patch_index, + bool enabled, std::string* status_out) const { + if (!patch_set_enabled_callback_) { + if (status_out) { + *status_out = "Patch management is unavailable."; + } + return false; + } + return patch_set_enabled_callback_(title_id, filename, patch_index, enabled, status_out); + } + + void set_controller_state_callback(ControllerStateCallback callback) { + controller_state_callback_ = std::move(callback); + } + bool GetControllerState(uint32_t user_index, hid::X_INPUT_STATE* out_state) const { + if (!controller_state_callback_) { + return false; + } + return controller_state_callback_(user_index, out_state); + } + + void set_message_box_prompt_callback(MessageBoxPromptCallback callback) { + message_box_prompt_callback_ = std::move(callback); + } + bool PromptMessageBoxUI(const std::string& title, const std::string& text, + const std::vector& buttons, uint32_t default_button, + uint32_t* selected_button_out) const; + + void set_keyboard_prompt_callback(KeyboardPromptCallback callback) { + keyboard_prompt_callback_ = std::move(callback); + } + bool PromptKeyboardUI(const std::string& title, const std::string& description, + const std::string& default_text, std::string* text_out, + bool* cancelled_out) const { + if (!keyboard_prompt_callback_) { + return false; + } + return keyboard_prompt_callback_(title, description, default_text, text_out, cancelled_out); + } + + void set_gameplay_input_blocked_callback(GameplayInputBlockedCallback callback) { + gameplay_input_blocked_callback_ = std::move(callback); + } + void SetGameplayInputBlocked(bool blocked) const { + if (gameplay_input_blocked_callback_) { + gameplay_input_blocked_callback_(blocked); + } + } + + void set_guest_display_refresh_cap_getter(GuestDisplayRefreshCapGetter callback) { + guest_display_refresh_cap_getter_ = std::move(callback); + } + bool GetGuestDisplayRefreshCap() const { + if (!guest_display_refresh_cap_getter_) { + return true; + } + return guest_display_refresh_cap_getter_(); + } + void set_guest_display_refresh_cap_setter(GuestDisplayRefreshCapSetter callback) { + guest_display_refresh_cap_setter_ = std::move(callback); + } + void SetGuestDisplayRefreshCap(bool capped) const { + if (guest_display_refresh_cap_setter_) { + guest_display_refresh_cap_setter_(capped); + } + } + + void set_present_letterbox_getter(PresentLetterboxGetter callback) { + present_letterbox_getter_ = std::move(callback); + } + bool GetPresentLetterbox() const { + if (!present_letterbox_getter_) { + return true; + } + return present_letterbox_getter_(); + } + void set_present_letterbox_setter(PresentLetterboxSetter callback) { + present_letterbox_setter_ = std::move(callback); + } + void SetPresentLetterbox(bool enabled) const { + if (present_letterbox_setter_) { + present_letterbox_setter_(enabled); + } + } + + void set_guest_display_aspect_ratio_callback(GuestDisplayAspectRatioCallback callback) { + guest_display_aspect_ratio_callback_ = std::move(callback); + } + std::pair GetGuestDisplayAspectRatio() const { + if (!guest_display_aspect_ratio_callback_) { + return {16, 9}; + } + return guest_display_aspect_ratio_callback_(); + } + void NotifyGuestDisplayAspectRatioChanged() const; + + // Callback invoked when the view layout changes (rotation, resize, etc.). + using LayoutChangedCallback = std::function; + void set_layout_changed_callback(LayoutChangedCallback callback) { + layout_changed_callback_ = std::move(callback); + } + void NotifyLayoutChanged() { + if (layout_changed_callback_) { + layout_changed_callback_(); + } + } + + hid::touch::IOSTouchRuntimeModel* touch_runtime_model() const { return touch_runtime_model_; } + void set_touch_runtime_model(hid::touch::IOSTouchRuntimeModel* runtime_model) { + touch_runtime_model_ = runtime_model; + } + + private: + UIView* metal_view_ = nullptr; + UIViewController* view_controller_ = nullptr; + hid::touch::IOSTouchRuntimeModel* touch_runtime_model_ = nullptr; + GameLaunchCallback game_launch_callback_; + ProfilesListCallback profiles_list_callback_; + ProfileCreateCallback profile_create_callback_; + ProfileSignInCallback profile_sign_in_callback_; + GameTerminateCallback game_terminate_callback_; + TitleUpdateInstallCallback title_update_install_callback_; + GameExitedCallback game_exited_callback_; + SignInUIPromptCallback signin_ui_prompt_callback_; + AchievementsSnapshotCallback achievements_snapshot_callback_; + AchievementsUIPromptCallback achievements_ui_prompt_callback_; + AchievementNotificationCallback achievement_notification_callback_; + PatchFilesListCallback patch_files_list_callback_; + PatchDiscoverySummaryCallback patch_discovery_summary_callback_; + PatchSetEnabledCallback patch_set_enabled_callback_; + ControllerStateCallback controller_state_callback_; + ProfileServicesReadyCallback profile_services_ready_callback_; + MessageBoxPromptCallback message_box_prompt_callback_; + KeyboardPromptCallback keyboard_prompt_callback_; + GameplayInputBlockedCallback gameplay_input_blocked_callback_; + GuestDisplayRefreshCapGetter guest_display_refresh_cap_getter_; + GuestDisplayRefreshCapSetter guest_display_refresh_cap_setter_; + PresentLetterboxGetter present_letterbox_getter_; + PresentLetterboxSetter present_letterbox_setter_; + GuestDisplayAspectRatioCallback guest_display_aspect_ratio_callback_; + LayoutChangedCallback layout_changed_callback_; +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_WINDOWED_APP_CONTEXT_IOS_H_ diff --git a/src/xenia/ui/ios/app/windowed_app_context_ios.mm b/src/xenia/ui/ios/app/windowed_app_context_ios.mm new file mode 100644 index 000000000..1e483c15f --- /dev/null +++ b/src/xenia/ui/ios/app/windowed_app_context_ios.mm @@ -0,0 +1,159 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/app/windowed_app_context_ios.h" + +#import +#import + +namespace xe { +namespace ui { + +IOSWindowedAppContext::IOSWindowedAppContext() = default; + +IOSWindowedAppContext::~IOSWindowedAppContext() = default; + +bool IOSWindowedAppContext::PromptMessageBoxUI(const std::string& title, const std::string& text, + const std::vector& buttons, + uint32_t default_button, + uint32_t* selected_button_out) const { + if (message_box_prompt_callback_) { + return message_box_prompt_callback_(title, text, buttons, default_button, selected_button_out); + } + if (!view_controller_) { + if (selected_button_out) { + *selected_button_out = default_button; + } + return false; + } + + __block uint32_t selected_button = default_button; + __block BOOL shown = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + NSString* title_ns = + title.empty() ? @"Message Box" : [NSString stringWithUTF8String:title.c_str()]; + NSString* text_ns = text.empty() ? @"" : [NSString stringWithUTF8String:text.c_str()]; + + NSMutableArray* prompt_buttons = [NSMutableArray arrayWithCapacity:buttons.size()]; + for (const auto& button : buttons) { + NSString* button_ns = button.empty() ? @"OK" : [NSString stringWithUTF8String:button.c_str()]; + [prompt_buttons addObject:button_ns]; + } + if (prompt_buttons.count == 0) { + [prompt_buttons addObject:@"OK"]; + } + if (selected_button >= prompt_buttons.count) { + selected_button = static_cast(prompt_buttons.count - 1); + } + + UIViewController* base_view_controller = view_controller_; + if ([NSThread isMainThread]) { + UIViewController* presenter = base_view_controller; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + if (!presenter) { + if (selected_button_out) { + *selected_button_out = selected_button; + } + return false; + } + + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title_ns + message:text_ns + preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* preferred_action = nil; + for (NSUInteger i = 0; i < prompt_buttons.count; ++i) { + UIAlertAction* action = + [UIAlertAction actionWithTitle:prompt_buttons[i] + style:UIAlertActionStyleDefault + handler:nil]; + [alert addAction:action]; + if (i == selected_button) { + preferred_action = action; + } + } + if (preferred_action) { + alert.preferredAction = preferred_action; + } + [presenter presentViewController:alert animated:YES completion:nil]; + if (selected_button_out) { + *selected_button_out = selected_button; + } + return true; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController* presenter = base_view_controller; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + if (!presenter) { + dispatch_semaphore_signal(sem); + return; + } + + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title_ns + message:text_ns + preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* preferred_action = nil; + for (NSUInteger i = 0; i < prompt_buttons.count; ++i) { + UIAlertAction* action = + [UIAlertAction actionWithTitle:prompt_buttons[i] + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* picked_action) { + selected_button = static_cast(i); + shown = YES; + dispatch_semaphore_signal(sem); + }]; + [alert addAction:action]; + if (i == selected_button) { + preferred_action = action; + } + } + if (preferred_action) { + alert.preferredAction = preferred_action; + } + [presenter presentViewController:alert animated:YES completion:nil]; + }); + + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + if (selected_button_out) { + *selected_button_out = selected_button; + } + return shown ? true : false; +} + +void IOSWindowedAppContext::NotifyUILoopOfPendingFunctions() { + // Use GCD to schedule pending function execution on the main thread. + dispatch_async(dispatch_get_main_queue(), ^{ + ExecutePendingFunctionsFromUIThread(); + }); +} + +void IOSWindowedAppContext::NotifyGuestDisplayAspectRatioChanged() const { + UIViewController* view_controller = view_controller_; + if (!view_controller) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [view_controller.view setNeedsLayout]; + }); +} + +void IOSWindowedAppContext::PlatformQuitFromUIThread() { + // iOS apps don't self-terminate; the OS manages the lifecycle. + // Suspending to background is the closest equivalent. +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/app/windowed_app_main_ios.mm b/src/xenia/ui/ios/app/windowed_app_main_ios.mm new file mode 100644 index 000000000..ef7d44c87 --- /dev/null +++ b/src/xenia/ui/ios/app/windowed_app_main_ios.mm @@ -0,0 +1,23 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +// iOS entry point. Each piece of the launcher / settings / compatibility UI +// lives in its own ios_*.{h,mm} pair under src/xenia/ui/; this file is just +// the UIApplicationMain trampoline that hands control to XeniaAppDelegate. + +#import + +#import "xenia/ui/ios/app/ios_app_delegate.h" + +int main(int argc, char* argv[]) { + NSLog(@"iOS: skipping ptrace/debugger JIT setup; using normal W^X app flow."); + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([XeniaAppDelegate class])); + } +} diff --git a/src/xenia/ui/ios/game/file_picker_ios.mm b/src/xenia/ui/ios/game/file_picker_ios.mm new file mode 100644 index 000000000..03fce607a --- /dev/null +++ b/src/xenia/ui/ios/game/file_picker_ios.mm @@ -0,0 +1,137 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/file_picker.h" + +#import +#import + +#include "xenia/base/logging.h" + +// Delegate for handling document picker results. +@interface XeniaDocumentPickerDelegate : NSObject +@property(nonatomic, assign) bool completed; +@property(nonatomic, assign) bool cancelled; +@property(nonatomic, strong) NSArray* selectedURLs; +@end + +@implementation XeniaDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController*)controller + didPickDocumentsAtURLs:(NSArray*)urls { + self.selectedURLs = urls; + self.completed = true; + self.cancelled = false; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController*)controller { + self.completed = true; + self.cancelled = true; +} + +@end + +namespace xe { +namespace ui { + +class IOSFilePicker : public FilePicker { + public: + IOSFilePicker() = default; + ~IOSFilePicker() override = default; + + bool Show(Window* parent_window) override; +}; + +std::unique_ptr FilePicker::Create() { return std::make_unique(); } + +bool IOSFilePicker::Show(Window* parent_window) { + @autoreleasepool { + // Build the list of content types from extensions. + NSMutableArray* content_types = [NSMutableArray array]; + + // Always include common game file types. + [content_types addObject:[UTType typeWithFilenameExtension:@"iso"]]; + [content_types addObject:[UTType typeWithFilenameExtension:@"xex"]]; + [content_types addObject:[UTType typeWithFilenameExtension:@"zar"]]; + // STFS containers often have no extension, so include generic data. + [content_types addObject:UTTypeData]; + + // Add any extensions the caller specified. + for (const auto& ext_pair : extensions_) { + NSString* ext = [NSString stringWithUTF8String:ext_pair.second.c_str()]; + // Extensions may contain semicolons for multiple patterns. + NSArray* parts = [ext componentsSeparatedByString:@";"]; + for (NSString* part in parts) { + NSString* clean = + [part stringByTrimmingCharactersInSet:[NSCharacterSet + characterSetWithCharactersInString:@"*. "]]; + if (clean.length > 0) { + UTType* type = [UTType typeWithFilenameExtension:clean]; + if (type) { + [content_types addObject:type]; + } + } + } + } + + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:content_types]; + picker.allowsMultipleSelection = multi_selection_ ? YES : NO; + + if (!initial_directory_.empty()) { + NSString* dir_path = [NSString stringWithUTF8String:initial_directory_.c_str()]; + NSURL* dir_url = [NSURL fileURLWithPath:dir_path isDirectory:YES]; + picker.directoryURL = dir_url; + } + + XeniaDocumentPickerDelegate* delegate = [[XeniaDocumentPickerDelegate alloc] init]; + delegate.completed = false; + delegate.cancelled = false; + picker.delegate = delegate; + + // Present the picker from the root view controller. + UIViewController* presenting_vc = nil; + UIWindowScene* scene = + (UIWindowScene*)[[UIApplication sharedApplication].connectedScenes anyObject]; + if (scene) { + presenting_vc = scene.keyWindow.rootViewController; + } + if (!presenting_vc) { + XELOGE("IOSFilePicker: No presenting view controller available"); + return false; + } + + // Present modally and run the run loop until the delegate fires. + [presenting_vc presentViewController:picker animated:YES completion:nil]; + + // Spin the main run loop until the picker completes. + // This matches the synchronous Show() API contract. + while (!delegate.completed) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + + if (delegate.cancelled || !delegate.selectedURLs || delegate.selectedURLs.count == 0) { + return false; + } + + selected_files_.clear(); + for (NSURL* url in delegate.selectedURLs) { + // Start security-scoped access for files outside the app sandbox. + [url startAccessingSecurityScopedResource]; + std::string path_str = std::string([url.path UTF8String]); + selected_files_.push_back(std::filesystem::path(path_str)); + } + + return !selected_files_.empty(); + } +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/game/ios_achievement_notification_presenter.h b/src/xenia/ui/ios/game/ios_achievement_notification_presenter.h new file mode 100644 index 000000000..198d26f95 --- /dev/null +++ b/src/xenia/ui/ios/game/ios_achievement_notification_presenter.h @@ -0,0 +1,24 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_ACHIEVEMENT_NOTIFICATION_PRESENTER_H_ +#define XENIA_UI_IOS_ACHIEVEMENT_NOTIFICATION_PRESENTER_H_ + +#import + +#include "xenia/ui/achievement_notification_payload.h" + +@interface XeniaIOSAchievementNotificationPresenter : NSObject + +- (void)presentPayload:(const xe::ui::AchievementNotificationPayload&)payload inView:(UIView*)view; +- (void)dismissAll; + +@end + +#endif // XENIA_UI_IOS_ACHIEVEMENT_NOTIFICATION_PRESENTER_H_ diff --git a/src/xenia/ui/ios/game/ios_achievement_notification_presenter.mm b/src/xenia/ui/ios/game/ios_achievement_notification_presenter.mm new file mode 100644 index 000000000..9e7b5a83f --- /dev/null +++ b/src/xenia/ui/ios/game/ios_achievement_notification_presenter.mm @@ -0,0 +1,511 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/game/ios_achievement_notification_presenter.h" + +#include +#include +#include + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +constexpr NSTimeInterval kXeniaAchievementToastHoldSeconds = 4.5; +constexpr CGFloat kXeniaAchievementToastHeight = 74.0; +constexpr CGFloat kXeniaAchievementToastMargin = 18.0; +constexpr size_t kXeniaAchievementToastQueueLimit = 5; + +NSString* NSStringFromStdString(const std::string& value) { + if (value.empty()) { + return @""; + } + NSString* string = [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; + NSString* result = [string autorelease]; + return result ? result : @""; +} + +UIImage* ImageFromPayload(const xe::ui::AchievementNotificationPayload& payload) { + static NSCache* icon_cache = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + icon_cache = [[NSCache alloc] init]; + icon_cache.countLimit = 32; + }); + + NSString* cache_key = [NSString stringWithFormat:@"%08X:%u", payload.title_id, + payload.achievement_id]; + UIImage* cached = [icon_cache objectForKey:cache_key]; + if (cached) { + return cached; + } + + if (!payload.icon_data.empty()) { + NSData* data = [NSData dataWithBytes:payload.icon_data.data() length:payload.icon_data.size()]; + UIImage* image = [UIImage imageWithData:data]; + if (image) { + [icon_cache setObject:image forKey:cache_key]; + return image; + } + } + if (@available(iOS 13.0, *)) { + return [UIImage systemImageNamed:@"trophy.fill"]; + } + return nil; +} + +xe::ui::AchievementNotificationPayload MakeOverflowPayload(NSUInteger count) { + xe::ui::AchievementNotificationPayload payload; + payload.title = "Achievements"; + payload.description = [[NSString stringWithFormat:@"+%lu more unlocked", + static_cast(count)] UTF8String]; + return payload; +} + +CGFloat FinalToastWidthForView(UIView* host_view) { + CGFloat safe_width = CGRectGetWidth(host_view.bounds) - host_view.safeAreaInsets.left - + host_view.safeAreaInsets.right; + if (safe_width <= 0.0) { + safe_width = CGRectGetWidth(host_view.bounds); + } + const CGFloat maximum_width = std::max(0.0, safe_width - 32.0); + if (maximum_width <= 0.0) { + return 0.0; + } + const CGFloat preferred_width = + std::max(300.0, std::min(520.0, safe_width * 0.52)); + return std::min(preferred_width, maximum_width); +} + +enum class ToastHorizontalPosition { + kLeft, + kCenter, + kRight, +}; + +enum class ToastVerticalPosition { + kTop, + kCenter, + kBottom, +}; + +ToastHorizontalPosition HorizontalPosition(uint8_t position_id) { + switch (position_id) { + case 4: + case 5: + case 6: + return ToastHorizontalPosition::kLeft; + case 8: + case 9: + case 10: + return ToastHorizontalPosition::kRight; + default: + return ToastHorizontalPosition::kCenter; + } +} + +ToastVerticalPosition VerticalPosition(uint8_t position_id) { + switch (position_id) { + case 1: + case 5: + case 9: + return ToastVerticalPosition::kTop; + case 0: + case 4: + case 8: + return ToastVerticalPosition::kCenter; + default: + return ToastVerticalPosition::kBottom; + } +} + +} // namespace + +@interface XeniaIOSAchievementNotificationView : UIView +@property(nonatomic, retain) UIView* textContainerView; +- (instancetype)initWithPayload:(const xe::ui::AchievementNotificationPayload&)payload; +@end + +@implementation XeniaIOSAchievementNotificationView + +- (instancetype)initWithPayload:(const xe::ui::AchievementNotificationPayload&)payload { + self = [super initWithFrame:CGRectZero]; + if (!self) { + return nil; + } + + self.translatesAutoresizingMaskIntoConstraints = NO; + self.backgroundColor = [UIColor clearColor]; + self.clipsToBounds = YES; + self.userInteractionEnabled = YES; + self.layer.cornerRadius = XeniaRadiusXxl; + self.layer.borderWidth = 1.0; + self.layer.borderColor = [XeniaTheme border].CGColor; + + // Glass backdrop on iOS 26+, SystemMaterial fallback on iOS 18-25. + // Inserted at index 0 so the icon/text row stacks above it. Inlined + // rather than going through xe_apply_glass_card_chrome because the + // notification uses XeniaRadiusXxl (24pt) — the helper hardcodes + // XeniaRadiusLg (12pt) for the smaller toast use case. + UIVisualEffectView* backdrop = + [[UIVisualEffectView alloc] initWithEffect:xe_make_chrome_visual_effect(NO)]; + backdrop.frame = self.bounds; + backdrop.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + backdrop.userInteractionEnabled = NO; + [self insertSubview:backdrop atIndex:0]; + [backdrop release]; + + xe_apply_shadow_token(self, XeniaShadowElevationElevated); + + UIImageView* icon_view = + [[[UIImageView alloc] initWithImage:ImageFromPayload(payload)] autorelease]; + icon_view.translatesAutoresizingMaskIntoConstraints = NO; + icon_view.contentMode = UIViewContentModeScaleAspectFit; + icon_view.tintColor = [XeniaTheme accent]; + + UILabel* title_label = [[[UILabel alloc] init] autorelease]; + title_label.text = NSStringFromStdString(payload.title); + title_label.textColor = [XeniaTheme textPrimary]; + title_label.numberOfLines = 1; + title_label.adjustsFontForContentSizeCategory = YES; + title_label.adjustsFontSizeToFitWidth = YES; + title_label.minimumScaleFactor = 0.82; + xe_apply_label_font(title_label, UIFontTextStyleSubheadline, 15.0, UIFontWeightSemibold); + + UILabel* description_label = [[[UILabel alloc] init] autorelease]; + description_label.text = NSStringFromStdString(payload.description); + description_label.textColor = [XeniaTheme textSecondary]; + description_label.numberOfLines = 1; + description_label.adjustsFontForContentSizeCategory = YES; + description_label.adjustsFontSizeToFitWidth = YES; + description_label.minimumScaleFactor = 0.78; + xe_apply_label_font(description_label, UIFontTextStyleFootnote, 13.0, UIFontWeightMedium); + + UIStackView* text_stack = [[[UIStackView alloc] + initWithArrangedSubviews:@[ title_label, description_label ]] autorelease]; + text_stack.translatesAutoresizingMaskIntoConstraints = NO; + text_stack.axis = UILayoutConstraintAxisVertical; + text_stack.spacing = 2.0; + text_stack.alignment = UIStackViewAlignmentFill; + self.textContainerView = text_stack; + + UIStackView* row = + [[[UIStackView alloc] initWithArrangedSubviews:@[ icon_view, text_stack ]] autorelease]; + row.translatesAutoresizingMaskIntoConstraints = NO; + row.axis = UILayoutConstraintAxisHorizontal; + row.alignment = UIStackViewAlignmentCenter; + row.spacing = 12.0; + row.layoutMargins = UIEdgeInsetsMake(8.0, 10.0, 8.0, 16.0); + row.layoutMarginsRelativeArrangement = YES; + [self addSubview:row]; + + [NSLayoutConstraint activateConstraints:@[ + [row.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [row.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [row.topAnchor constraintEqualToAnchor:self.topAnchor], + [row.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + [icon_view.widthAnchor constraintEqualToConstant:56.0], + [icon_view.heightAnchor constraintEqualToConstant:56.0] + ]]; + + self.isAccessibilityElement = YES; + self.accessibilityLabel = + [NSString stringWithFormat:@"%@, %@", title_label.text, description_label.text]; + + return self; +} + +- (void)dealloc { + [_textContainerView release]; + [super dealloc]; +} + +@end + +@implementation XeniaIOSAchievementNotificationPresenter { + std::deque pending_payloads_; + UIView* host_view_; + UIView* active_banner_; + NSTimer* dismissal_timer_; + NSUInteger overflow_count_; + BOOL presenting_; +} + +- (instancetype)init { + self = [super init]; + if (!self) { + return nil; + } + host_view_ = nil; + active_banner_ = nil; + dismissal_timer_ = nil; + overflow_count_ = 0; + presenting_ = NO; + return self; +} + +- (void)dealloc { + [self dismissAll]; + [super dealloc]; +} + +- (void)presentPayload:(const xe::ui::AchievementNotificationPayload&)payload inView:(UIView*)view { + if (!view) { + return; + } + host_view_ = view; + if (pending_payloads_.size() >= kXeniaAchievementToastQueueLimit) { + ++overflow_count_; + return; + } + pending_payloads_.push_back(payload); + if (!presenting_) { + [self presentNextPayload]; + } +} + +- (void)dismissAll { + [dismissal_timer_ invalidate]; + [dismissal_timer_ release]; + dismissal_timer_ = nil; + pending_payloads_.clear(); + overflow_count_ = 0; + if (active_banner_) { + [active_banner_.layer removeAllAnimations]; + [active_banner_ removeFromSuperview]; + [active_banner_ release]; + active_banner_ = nil; + } + presenting_ = NO; +} + +- (void)presentNextPayload { + if (pending_payloads_.empty()) { + if (overflow_count_ > 0) { + pending_payloads_.push_back(MakeOverflowPayload(overflow_count_)); + overflow_count_ = 0; + } else { + presenting_ = NO; + return; + } + } + if (pending_payloads_.empty()) { + presenting_ = NO; + return; + } + if (!host_view_ || !host_view_.window) { + pending_payloads_.clear(); + presenting_ = NO; + return; + } + + presenting_ = YES; + xe::ui::AchievementNotificationPayload payload = pending_payloads_.front(); + pending_payloads_.pop_front(); + + const CGFloat final_width = FinalToastWidthForView(host_view_); + if (final_width <= 0.0) { + presenting_ = NO; + [self presentNextPayload]; + return; + } + + XeniaIOSAchievementNotificationView* banner = + [[[XeniaIOSAchievementNotificationView alloc] initWithPayload:payload] autorelease]; + banner.alpha = 0.0; + banner.transform = CGAffineTransformMakeScale(0.2, 1.0); + banner.textContainerView.alpha = 0.0; + active_banner_ = [banner retain]; + UITapGestureRecognizer* tap = + [[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(dismissCurrentBanner:)] autorelease]; + [banner addGestureRecognizer:tap]; + UISwipeGestureRecognizer* swipe_up = + [[[UISwipeGestureRecognizer alloc] initWithTarget:self + action:@selector(dismissCurrentBanner:)] autorelease]; + swipe_up.direction = UISwipeGestureRecognizerDirectionUp; + [banner addGestureRecognizer:swipe_up]; + UISwipeGestureRecognizer* swipe_side = + [[[UISwipeGestureRecognizer alloc] initWithTarget:self + action:@selector(dismissCurrentBanner:)] autorelease]; + swipe_side.direction = UISwipeGestureRecognizerDirectionLeft | + UISwipeGestureRecognizerDirectionRight; + [banner addGestureRecognizer:swipe_side]; + + [host_view_ addSubview:banner]; + UILayoutGuide* safe_area = host_view_.safeAreaLayoutGuide; + NSMutableArray* constraints = [NSMutableArray arrayWithArray:@[ + [banner.widthAnchor constraintEqualToConstant:final_width], + [banner.heightAnchor constraintEqualToConstant:kXeniaAchievementToastHeight] + ]]; + + switch (HorizontalPosition(payload.position_id)) { + case ToastHorizontalPosition::kLeft: + [constraints + addObject:[banner.leadingAnchor constraintEqualToAnchor:safe_area.leadingAnchor + constant:kXeniaAchievementToastMargin]]; + break; + case ToastHorizontalPosition::kRight: + [constraints + addObject:[banner.trailingAnchor constraintEqualToAnchor:safe_area.trailingAnchor + constant:-kXeniaAchievementToastMargin]]; + break; + case ToastHorizontalPosition::kCenter: + [constraints + addObject:[banner.centerXAnchor constraintEqualToAnchor:safe_area.centerXAnchor]]; + break; + } + + switch (VerticalPosition(payload.position_id)) { + case ToastVerticalPosition::kTop: + [constraints + addObject:[banner.topAnchor constraintEqualToAnchor:safe_area.topAnchor + constant:kXeniaAchievementToastMargin]]; + break; + case ToastVerticalPosition::kCenter: + [constraints + addObject:[banner.centerYAnchor constraintEqualToAnchor:safe_area.centerYAnchor]]; + break; + case ToastVerticalPosition::kBottom: + [constraints + addObject:[banner.bottomAnchor constraintEqualToAnchor:safe_area.bottomAnchor + constant:-kXeniaAchievementToastMargin]]; + break; + } + + [NSLayoutConstraint activateConstraints:constraints]; + [host_view_ layoutIfNeeded]; + + UINotificationFeedbackGenerator* feedback = + [[[UINotificationFeedbackGenerator alloc] init] autorelease]; + [feedback notificationOccurred:UINotificationFeedbackTypeSuccess]; + [self animateBanner:banner]; +} + +- (void)animateBanner:(XeniaIOSAchievementNotificationView*)banner { + if (UIAccessibilityIsReduceMotionEnabled()) { + banner.transform = CGAffineTransformMakeScale(0.98, 0.98); + banner.textContainerView.alpha = 1.0; + [UIView animateWithDuration:0.16 + delay:0.0 + options:UIViewAnimationOptionCurveEaseOut | UIViewAnimationOptionAllowUserInteraction + animations:^{ + banner.alpha = 1.0; + banner.transform = CGAffineTransformIdentity; + } + completion:^(__unused BOOL finished) { + [self scheduleDismissForBanner:banner reduceMotion:YES]; + }]; + return; + } + + [UIView animateWithDuration:0.36 + delay:0.0 + options:UIViewAnimationOptionCurveEaseOut | UIViewAnimationOptionAllowUserInteraction + animations:^{ + banner.alpha = 1.0; + banner.transform = CGAffineTransformMakeScale(1.1, 1.0); + banner.textContainerView.alpha = 1.0; + } + completion:^(__unused BOOL finished) { + [UIView animateWithDuration:0.12 + delay:0.0 + options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAllowUserInteraction + animations:^{ + banner.transform = CGAffineTransformIdentity; + } + completion:^(__unused BOOL settle_finished) { + [self scheduleDismissForBanner:banner reduceMotion:NO]; + }]; + }]; +} + +- (void)scheduleDismissForBanner:(XeniaIOSAchievementNotificationView*)banner + reduceMotion:(BOOL)reduce_motion { + (void)reduce_motion; + [dismissal_timer_ invalidate]; + [dismissal_timer_ release]; + dismissal_timer_ = + [[NSTimer scheduledTimerWithTimeInterval:kXeniaAchievementToastHoldSeconds + target:self + selector:@selector(dismissTimerFired:) + userInfo:banner + repeats:NO] retain]; +} + +- (void)dismissTimerFired:(NSTimer*)timer { + if (timer != dismissal_timer_) { + return; + } + UIView* banner = (UIView*)timer.userInfo; + [dismissal_timer_ release]; + dismissal_timer_ = nil; + if (banner != active_banner_) { + return; + } + [self dismissCurrentBannerAnimated:YES]; +} + +- (void)dismissCurrentBanner:(id)sender { + (void)sender; + [self dismissCurrentBannerAnimated:YES]; +} + +- (void)dismissCurrentBannerAnimated:(BOOL)animated { + if (!active_banner_) { + return; + } + [dismissal_timer_ invalidate]; + [dismissal_timer_ release]; + dismissal_timer_ = nil; + UIView* banner = active_banner_; + if (!animated || UIAccessibilityIsReduceMotionEnabled()) { + [UIView animateWithDuration:animated ? 0.18 : 0.0 + delay:0.0 + options:UIViewAnimationOptionCurveEaseIn | UIViewAnimationOptionAllowUserInteraction + animations:^{ + banner.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + [self finishBanner:banner]; + }]; + return; + } + [UIView animateWithDuration:0.32 + delay:0.0 + options:UIViewAnimationOptionCurveEaseIn | UIViewAnimationOptionAllowUserInteraction + animations:^{ + banner.alpha = 0.0; + banner.transform = CGAffineTransformMakeScale(0.2, 1.0); + ((XeniaIOSAchievementNotificationView*)banner).textContainerView.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + [self finishBanner:banner]; + }]; +} + +- (void)finishBanner:(UIView*)banner { + if (banner != active_banner_) { + return; + } + [dismissal_timer_ invalidate]; + [dismissal_timer_ release]; + dismissal_timer_ = nil; + [active_banner_ removeFromSuperview]; + [active_banner_ release]; + active_banner_ = nil; + presenting_ = NO; + [self presentNextPayload]; +} + +@end diff --git a/src/xenia/ui/ios/game/ios_achievements_view_controller.h b/src/xenia/ui/ios/game/ios_achievements_view_controller.h new file mode 100644 index 000000000..fe22505e6 --- /dev/null +++ b/src/xenia/ui/ios/game/ios_achievements_view_controller.h @@ -0,0 +1,38 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_IOS_ACHIEVEMENTS_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_GAME_IOS_ACHIEVEMENTS_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace xe { +namespace ui { +class IOSWindowedAppContext; +} // namespace ui +} // namespace xe + +// Native iOS achievements sheet backed by XAM's existing GPD achievement +// data. It is intentionally read-only: games still unlock and persist +// achievements through the shared AchievementManager path. +@interface XeniaAchievementsViewController : XESheetTableViewController + +@property(nonatomic, copy) void (^dismissalHandler)(void); + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)appContext + userIndex:(uint32_t)userIndex + titleID:(uint32_t)titleID; + +@end + +#endif // XENIA_UI_IOS_GAME_IOS_ACHIEVEMENTS_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/game/ios_achievements_view_controller.mm b/src/xenia/ui/ios/game/ios_achievements_view_controller.mm new file mode 100644 index 000000000..dfe68469d --- /dev/null +++ b/src/xenia/ui/ios/game/ios_achievements_view_controller.mm @@ -0,0 +1,308 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/game/ios_achievements_view_controller.h" + +#include +#include +#include + +#import "xenia/ui/ios/app/windowed_app_context_ios.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +NSString* ProgressText(const xe::ui::IOSAchievementsSnapshot& snapshot) { + return [NSString stringWithFormat:@"%u/%u unlocked", + snapshot.achievements_unlocked, + snapshot.achievements_total]; +} + +NSString* GamerscoreText(const xe::ui::IOSAchievementsSnapshot& snapshot) { + return [NSString stringWithFormat:@"%u/%u G", + snapshot.gamerscore_earned, + snapshot.gamerscore_total]; +} + +UIImage* ImageForAchievement(const xe::ui::IOSAchievementEntry& entry, BOOL revealed) { + if (revealed && !entry.icon_data.empty()) { + NSData* data = [NSData dataWithBytes:entry.icon_data.data() + length:entry.icon_data.size()]; + UIImage* image = [UIImage imageWithData:data]; + if (image) { + return image; + } + } + return [UIImage systemImageNamed:entry.unlocked ? @"checkmark.seal.fill" : @"lock.fill"]; +} + +} // namespace + +@implementation XeniaAchievementsViewController { + xe::ui::IOSWindowedAppContext* app_context_; + uint32_t user_index_; + uint32_t title_id_; + BOOL show_locked_info_; + xe::ui::IOSAchievementsSnapshot snapshot_; + void (^dismissal_handler_)(void); +} + +@synthesize dismissalHandler = dismissal_handler_; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)appContext + userIndex:(uint32_t)userIndex + titleID:(uint32_t)titleID { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + app_context_ = appContext; + user_index_ = userIndex; + title_id_ = titleID; + show_locked_info_ = NO; + } + return self; +} + +- (void)dealloc { + [dismissal_handler_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Achievements"; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 88.0; + if (@available(iOS 15.0, *)) { + self.tableView.sectionHeaderTopPadding = 0; + } + + if (app_context_) { + snapshot_ = app_context_->LoadAchievementsSnapshot(user_index_, title_id_); + } + std::sort(snapshot_.achievements.begin(), snapshot_.achievements.end(), + [](const xe::ui::IOSAchievementEntry& a, + const xe::ui::IOSAchievementEntry& b) { + if (a.unlocked != b.unlocked) { + return a.unlocked > b.unlocked; + } + return a.achievement_id < b.achievement_id; + }); + + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(closeTapped:)] autorelease]; + self.navigationItem.rightBarButtonItem = + [[[UIBarButtonItem alloc] initWithTitle:@"Show Hidden" + style:UIBarButtonItemStylePlain + target:self + action:@selector(toggleHiddenInfo:)] autorelease]; + self.tableView.tableHeaderView = [self summaryHeaderView]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self updateHeaderLayout]; +} + +- (void)updateHeaderLayout { + UIView* header = self.tableView.tableHeaderView; + if (!header) { + return; + } + CGFloat width = CGRectGetWidth(self.tableView.bounds); + CGSize size = [header systemLayoutSizeFittingSize:CGSizeMake(width, 0) + withHorizontalFittingPriority:UILayoutPriorityRequired + verticalFittingPriority:UILayoutPriorityFittingSizeLevel]; + CGRect frame = header.frame; + if (fabs(frame.size.height - size.height) <= 0.5 && + fabs(frame.size.width - width) <= 0.5) { + return; + } + frame.size.width = width; + frame.size.height = ceil(size.height); + header.frame = frame; + self.tableView.tableHeaderView = header; +} + +- (UIView*)summaryHeaderView { + UIView* container = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 132)] autorelease]; + container.backgroundColor = [UIColor clearColor]; + + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [XeniaTheme bgSurface]; + card.layer.cornerRadius = 12.0; + card.layer.borderWidth = 0.5; + card.layer.borderColor = [XeniaTheme border].CGColor; + [container addSubview:card]; + + UILabel* title = [[[UILabel alloc] init] autorelease]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.textColor = [XeniaTheme textPrimary]; + title.numberOfLines = 2; + title.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + title.text = snapshot_.title_name.empty() + ? [NSString stringWithFormat:@"Title %08X", snapshot_.title_id] + : ToNSString(snapshot_.title_name); + [card addSubview:title]; + + UILabel* progress = [[[UILabel alloc] init] autorelease]; + progress.translatesAutoresizingMaskIntoConstraints = NO; + progress.textColor = [XeniaTheme textSecondary]; + progress.font = [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]; + progress.text = [NSString stringWithFormat:@"%@ %@", ProgressText(snapshot_), + GamerscoreText(snapshot_)]; + [card addSubview:progress]; + + UIProgressView* progress_view = + [[[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault] autorelease]; + progress_view.translatesAutoresizingMaskIntoConstraints = NO; + progress_view.progressTintColor = [XeniaTheme accent]; + progress_view.trackTintColor = [XeniaTheme bgSurface2]; + progress_view.progress = + snapshot_.achievements_total + ? static_cast(snapshot_.achievements_unlocked) / + static_cast(snapshot_.achievements_total) + : 0.0f; + [card addSubview:progress_view]; + + [NSLayoutConstraint activateConstraints:@[ + [card.topAnchor constraintEqualToAnchor:container.topAnchor constant:8.0], + [card.leadingAnchor constraintEqualToAnchor:container.leadingAnchor constant:16.0], + [card.trailingAnchor constraintEqualToAnchor:container.trailingAnchor constant:-16.0], + [card.bottomAnchor constraintEqualToAnchor:container.bottomAnchor constant:-8.0], + + [title.topAnchor constraintEqualToAnchor:card.topAnchor constant:14.0], + [title.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:14.0], + [title.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-14.0], + + [progress.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:8.0], + [progress.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], + [progress.trailingAnchor constraintEqualToAnchor:title.trailingAnchor], + + [progress_view.topAnchor constraintEqualToAnchor:progress.bottomAnchor constant:12.0], + [progress_view.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], + [progress_view.trailingAnchor constraintEqualToAnchor:title.trailingAnchor], + [progress_view.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-16.0], + ]]; + + return container; +} + +- (void)closeTapped:(id)sender { + (void)sender; + UIViewController* dismiss_target = self.navigationController ?: self; + void (^dismissal_handler)(void) = [dismissal_handler_ copy]; + [dismiss_target dismissViewControllerAnimated:YES + completion:^{ + if (dismissal_handler) { + dismissal_handler(); + } + [dismissal_handler release]; + }]; +} + +- (void)toggleHiddenInfo:(id)sender { + show_locked_info_ = !show_locked_info_; + self.navigationItem.rightBarButtonItem.title = + show_locked_info_ ? @"Hide Hidden" : @"Show Hidden"; + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:0] + withRowAnimation:UITableViewRowAnimationAutomatic]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section { + if (snapshot_.achievements.empty()) { + return 1; + } + return static_cast(snapshot_.achievements.size()); +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaAchievementCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + } + + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.accessoryView = nil; + UIListContentConfiguration* content = [UIListContentConfiguration subtitleCellConfiguration]; + content.textProperties.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + content.textProperties.color = [XeniaTheme textPrimary]; + content.textProperties.numberOfLines = 0; + content.secondaryTextProperties.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + content.secondaryTextProperties.color = [XeniaTheme textSecondary]; + content.secondaryTextProperties.numberOfLines = 0; + content.imageProperties.maximumSize = CGSizeMake(48.0, 48.0); + content.imageProperties.cornerRadius = 8.0; + content.imageToTextPadding = 12.0; + + if (snapshot_.achievements.empty()) { + content.text = @"No achievements data"; + content.secondaryText = @"This title has not published achievement metadata to the profile."; + content.image = [UIImage systemImageNamed:@"seal"]; + content.imageProperties.tintColor = [XeniaTheme textMuted]; + cell.contentConfiguration = content; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = content.text; + cell.accessibilityValue = content.secondaryText; + cell.accessibilityTraits = UIAccessibilityTraitStaticText; + return cell; + } + + const xe::ui::IOSAchievementEntry& entry = + snapshot_.achievements[static_cast(indexPath.row)]; + const BOOL revealed = entry.unlocked || show_locked_info_ || entry.show_unachieved; + NSString* title = revealed && !entry.title.empty() ? ToNSString(entry.title) + : @"Secret Achievement"; + std::string description; + if (entry.unlocked) { + description = entry.unlocked_description.empty() ? entry.locked_description + : entry.unlocked_description; + } else if (revealed) { + description = entry.locked_description; + } + if (description.empty()) { + description = revealed ? "No description available." : "Unlock to reveal details."; + } + + content.text = title; + content.secondaryText = ToNSString(description); + content.image = ImageForAchievement(entry, revealed); + if (!revealed || entry.icon_data.empty()) { + content.imageProperties.tintColor = + entry.unlocked ? [XeniaTheme accent] : [XeniaTheme textMuted]; + } + cell.contentConfiguration = content; + + UILabel* score = [[[UILabel alloc] init] autorelease]; + score.text = [NSString stringWithFormat:@"%u G", entry.gamerscore]; + score.textColor = entry.unlocked ? [XeniaTheme accent] : [XeniaTheme textMuted]; + score.font = [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]; + [score sizeToFit]; + cell.accessoryView = score; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = title; + cell.accessibilityValue = + [NSString stringWithFormat:@"%@, %u gamerscore, %@", + entry.unlocked ? @"Unlocked" : @"Locked", entry.gamerscore, + ToNSString(description)]; + cell.accessibilityTraits = UIAccessibilityTraitStaticText; + return cell; +} + +@end diff --git a/src/xenia/ui/ios/game/ios_metal_view.h b/src/xenia/ui/ios/game/ios_metal_view.h new file mode 100644 index 000000000..062925d3a --- /dev/null +++ b/src/xenia/ui/ios/game/ios_metal_view.h @@ -0,0 +1,21 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_METAL_VIEW_H_ +#define XENIA_UI_IOS_METAL_VIEW_H_ + +#import + +// UIView whose backing CALayer is a CAMetalLayer. Hosts the emulator's Metal +// surface and is owned by the root XeniaViewController. +@interface XeniaMetalView : UIView +@property(nonatomic, assign) CGFloat xeniaDrawableAspectRatio; +@end + +#endif // XENIA_UI_IOS_METAL_VIEW_H_ diff --git a/src/xenia/ui/ios/game/ios_metal_view.mm b/src/xenia/ui/ios/game/ios_metal_view.mm new file mode 100644 index 000000000..65a0879e7 --- /dev/null +++ b/src/xenia/ui/ios/game/ios_metal_view.mm @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/game/ios_metal_view.h" + +#import + +@implementation XeniaMetalView + ++ (Class)layerClass { + return [CAMetalLayer class]; +} + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _xeniaDrawableAspectRatio = 16.0 / 9.0; + } + return self; +} + +@end diff --git a/src/xenia/ui/ios/game/surface_ios.h b/src/xenia/ui/ios/game/surface_ios.h new file mode 100644 index 000000000..ee841b5da --- /dev/null +++ b/src/xenia/ui/ios/game/surface_ios.h @@ -0,0 +1,49 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_SURFACE_IOS_H_ +#define XENIA_UI_SURFACE_IOS_H_ + +#include + +#include "xenia/ui/surface.h" + +#ifdef __OBJC__ +@class UIView; +@class CAMetalLayer; +#else +struct UIView; +#ifndef VULKAN_METAL_H_ +struct CAMetalLayer; +#endif +#endif + +namespace xe { +namespace ui { + +class iOSUIViewSurface final : public Surface { + public: + explicit iOSUIViewSurface(UIView* view) : view_(view) {} + TypeIndex GetType() const override { return kTypeIndex_iOSUIView; } + UIView* view() const { return view_; } + void ConfigureMetalLayer(uint32_t drawable_width, uint32_t drawable_height, + double contents_scale) const; + CAMetalLayer* GetOrCreateMetalLayer() const; + + protected: + bool GetSizeImpl(uint32_t& width_out, uint32_t& height_out) const override; + + private: + UIView* view_; +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_SURFACE_IOS_H_ diff --git a/src/xenia/ui/ios/game/surface_ios.mm b/src/xenia/ui/ios/game/surface_ios.mm new file mode 100644 index 000000000..a2b3e8733 --- /dev/null +++ b/src/xenia/ui/ios/game/surface_ios.mm @@ -0,0 +1,120 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/game/surface_ios.h" + +#import "xenia/ui/ios/game/ios_metal_view.h" + +#import +#import + +#include +#include + +namespace xe { +namespace ui { + +namespace { +constexpr uint32_t kBaseDrawableHeight = 720; +constexpr double kDefaultDrawableAspect = 16.0 / 9.0; + +double GetUIViewDrawableAspect(UIView* view, CGRect bounds) { + if ([view respondsToSelector:@selector(xeniaDrawableAspectRatio)]) { + const CGFloat configured_aspect = + [(XeniaMetalView*)view xeniaDrawableAspectRatio]; + if (std::isfinite(static_cast(configured_aspect)) && + configured_aspect > 0.0) { + return static_cast(configured_aspect); + } + } + + double aspect = static_cast(bounds.size.width) / + std::max(1.0, static_cast(bounds.size.height)); + if (!std::isfinite(aspect) || aspect <= 0.0) { + aspect = kDefaultDrawableAspect; + } + return aspect; +} + +bool GetUIViewFixedDrawableSize(UIView* view, uint32_t& width_out, + uint32_t& height_out) { + if (!view) { + width_out = 0; + height_out = 0; + return false; + } + CGRect bounds = view.bounds; + if (CGRectIsEmpty(bounds)) { + width_out = 0; + height_out = 0; + return false; + } + const double aspect = GetUIViewDrawableAspect(view, bounds); + width_out = + std::max(1, uint32_t(std::llround(kBaseDrawableHeight * aspect))); + height_out = kBaseDrawableHeight; + return true; +} +} // namespace + +bool iOSUIViewSurface::GetSizeImpl(uint32_t& width_out, uint32_t& height_out) const { + // Keep presenter work at a stable 720p-class target. The UIView frame owns + // final screen placement and aspect changes from Fit / Stretch / Zoom. + return GetUIViewFixedDrawableSize(view_, width_out, height_out); +} + +void iOSUIViewSurface::ConfigureMetalLayer(uint32_t drawable_width, uint32_t drawable_height, + double contents_scale) const { + CAMetalLayer* metal_layer = GetOrCreateMetalLayer(); + if (!metal_layer) { + return; + } + if (contents_scale <= 0.0) { + contents_scale = 1.0; + } + CALayer* view_layer = view_.layer; + if (metal_layer != view_layer) { + metal_layer.frame = view_layer.bounds; + } + metal_layer.contentsScale = contents_scale; + metal_layer.drawableSize = + CGSizeMake(drawable_width * contents_scale, drawable_height * contents_scale); +} + +CAMetalLayer* iOSUIViewSurface::GetOrCreateMetalLayer() const { + if (!view_) { + return nullptr; + } + + // On iOS, UIView's layer can be set to a CAMetalLayer via layerClass + // or by replacing the layer directly. + CALayer* layer = [view_ layer]; + if ([layer isKindOfClass:[CAMetalLayer class]]) { + CAMetalLayer* metal_layer = static_cast(layer); + // The presenter owns guest aspect correction. The layer should only map the + // drawable to the current UIView bounds. + metal_layer.contentsGravity = kCAGravityResize; + return metal_layer; + } + + // Create and set a new CAMetalLayer. + CAMetalLayer* metalLayer = [CAMetalLayer layer]; + [view_ setNeedsLayout]; + [view_.layer addSublayer:metalLayer]; + metalLayer.frame = view_.bounds; + metalLayer.contentsScale = [UIScreen mainScreen].scale; + metalLayer.contentsGravity = kCAGravityResize; + metalLayer.framebufferOnly = YES; + metalLayer.opaque = YES; + + return metalLayer; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/game/window_ios.h b/src/xenia/ui/ios/game/window_ios.h new file mode 100644 index 000000000..b1e6e6a8e --- /dev/null +++ b/src/xenia/ui/ios/game/window_ios.h @@ -0,0 +1,84 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_WINDOW_IOS_H_ +#define XENIA_UI_WINDOW_IOS_H_ + +#include +#include +#include + +#include "xenia/ui/window.h" + +#ifdef __OBJC__ +@class UIView; +@class UIViewController; +@class CADisplayLink; +#else +typedef struct objc_object UIView; +typedef struct objc_object UIViewController; +typedef struct objc_object CADisplayLink; +#endif + +namespace xe { +namespace ui { + +class IOSWindowedAppContext; + +class iOSWindow : public Window { + public: + iOSWindow(WindowedAppContext& app_context, const std::string_view title, + uint32_t desired_logical_width, uint32_t desired_logical_height); + ~iOSWindow() override; + + UIView* view() const { return view_; } + UIViewController* view_controller() const { return view_controller_; } + + // Called from the app delegate when the view becomes available. + void SetNativeView(UIView* view, UIViewController* view_controller); + + // Called from display link or other paint trigger. + void TriggerPaint(); + + void SetGuestDisplayRefreshCapFromUIThread(bool capped); + + // Called from the view controller on layout changes. + void HandleSizeChange(); + + protected: + bool OpenImpl() override; + void RequestCloseImpl() override; + + uint32_t GetLatestDpiImpl() const override; + + void ApplyNewFullscreen() override; + void ApplyNewTitle() override {} + void LoadAndApplyIcon(const void* buffer, size_t size, + bool can_apply_state_in_current_phase) override {} + + std::unique_ptr CreateSurfaceImpl(Surface::TypeFlags allowed_types) override; + void RequestPaintImpl() override; + + private: + void SetupDisplayLink(); + void TeardownDisplayLink(); + void ApplyDisplayLinkFrameRateRangeFromUIThread(); + + UIView* view_ = nullptr; + UIViewController* view_controller_ = nullptr; + CADisplayLink* display_link_ = nullptr; + std::atomic paint_request_pending_{false}; + std::atomic display_link_driving_paint_{false}; + bool guest_display_refresh_capped_ = true; +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_WINDOW_IOS_H_ diff --git a/src/xenia/ui/ios/game/window_ios.mm b/src/xenia/ui/ios/game/window_ios.mm new file mode 100644 index 000000000..c2ddcace9 --- /dev/null +++ b/src/xenia/ui/ios/game/window_ios.mm @@ -0,0 +1,374 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/game/window_ios.h" + +#import "xenia/ui/ios/game/ios_metal_view.h" + +#import +#import + +#include +#include + +#include "xenia/base/logging.h" +#include "xenia/ui/ios/game/surface_ios.h" +#include "xenia/ui/ios/app/windowed_app_context_ios.h" + +namespace xe { +namespace ui { + +namespace { +constexpr uint32_t kBaseDrawableHeight = 720; +constexpr double kDefaultDrawableAspect = 16.0 / 9.0; + +double GetUIViewDrawableAspect(UIView* view, CGRect bounds) { + if ([view respondsToSelector:@selector(xeniaDrawableAspectRatio)]) { + const CGFloat configured_aspect = + [(XeniaMetalView*)view xeniaDrawableAspectRatio]; + if (std::isfinite(static_cast(configured_aspect)) && + configured_aspect > 0.0) { + return static_cast(configured_aspect); + } + } + + double aspect = static_cast(bounds.size.width) / + std::max(1.0, static_cast(bounds.size.height)); + if (!std::isfinite(aspect) || aspect <= 0.0) { + aspect = kDefaultDrawableAspect; + } + return aspect; +} + +bool GetUIViewFixedDrawableSize(UIView* view, uint32_t& width_out, + uint32_t& height_out) { + if (!view) { + width_out = 0; + height_out = 0; + return false; + } + CGRect bounds = view.bounds; + if (CGRectIsEmpty(bounds)) { + width_out = 0; + height_out = 0; + return false; + } + const double aspect = GetUIViewDrawableAspect(view, bounds); + width_out = + std::max(1, uint32_t(std::llround(kBaseDrawableHeight * aspect))); + height_out = kBaseDrawableHeight; + return true; +} +} // namespace + +std::unique_ptr Window::Create(WindowedAppContext& app_context, + const std::string_view title, uint32_t desired_logical_width, + uint32_t desired_logical_height) { + auto window = std::make_unique(app_context, title, desired_logical_width, + desired_logical_height); + return window; +} + +iOSWindow::iOSWindow(WindowedAppContext& app_context, const std::string_view title, + uint32_t desired_logical_width, uint32_t desired_logical_height) + : Window(app_context, title, desired_logical_width, desired_logical_height) {} + +iOSWindow::~iOSWindow() { + EnterDestructor(); + TeardownDisplayLink(); +} + +void iOSWindow::SetNativeView(UIView* view, UIViewController* view_controller) { + view_ = view; + view_controller_ = view_controller; +} + +void iOSWindow::HandleSizeChange() { + uint32_t width = 0; + uint32_t height = 0; + if (!GetUIViewFixedDrawableSize(view_, width, height)) { + return; + } + + WindowDestructionReceiver destruction_receiver(this); + OnActualSizeUpdate(width, height, destruction_receiver); +} + +void iOSWindow::TriggerPaint() { + paint_request_pending_.store(false, std::memory_order_release); + OnPaint(); +} + +void iOSWindow::SetGuestDisplayRefreshCapFromUIThread(bool capped) { + guest_display_refresh_capped_ = capped; + ApplyDisplayLinkFrameRateRangeFromUIThread(); +} + +bool iOSWindow::OpenImpl() { + auto& ios_context = static_cast(app_context()); + view_ = ios_context.metal_view(); + view_controller_ = ios_context.view_controller(); + + if (!view_) { + XELOGE("iOSWindow::OpenImpl: No Metal view available from app context"); + return false; + } + + // On iOS, the window is always effectively fullscreen. + OnDesiredFullscreenUpdate(true); + + // Report initial monitor info. + { + MonitorUpdateEvent monitor_event(this, true, true); + OnMonitorUpdate(monitor_event); + } + + CGRect bounds = [view_ bounds]; + CGFloat scale = [view_ contentScaleFactor]; + uint32_t width = 0; + uint32_t height = 0; + if (!GetUIViewFixedDrawableSize(view_, width, height)) { + XELOGE("iOSWindow::OpenImpl: Metal view has no drawable area"); + return false; + } + { + WindowDestructionReceiver destruction_receiver(this); + OnActualSizeUpdate(width, height, destruction_receiver); + if (destruction_receiver.IsWindowDestroyed()) { + return true; + } + } + + // Report initial focus (iOS app is always in focus when visible). + { + WindowDestructionReceiver destruction_receiver(this); + OnFocusUpdate(true, destruction_receiver); + if (destruction_receiver.IsWindowDestroyed()) { + return true; + } + } + + SetupDisplayLink(); + + XELOGI("iOSWindow: Opened ({}x{} drawable, view_bounds={}x{} @ {:.0f}x scale)", width, height, + static_cast(bounds.size.width), static_cast(bounds.size.height), + scale); + return true; +} + +void iOSWindow::RequestCloseImpl() { + // iOS apps don't close windows in the traditional sense. + // Signal the close lifecycle for proper cleanup. + WindowDestructionReceiver destruction_receiver(this); + OnBeforeClose(destruction_receiver); + if (destruction_receiver.IsWindowDestroyed()) { + return; + } + TeardownDisplayLink(); + OnAfterClose(); +} + +uint32_t iOSWindow::GetLatestDpiImpl() const { + if (view_) { + // iOS points are 1/163 of an inch at 1x scale. + // Standard DPI: 163 * scale factor. + CGFloat scale = [view_ contentScaleFactor]; + return static_cast(163.0 * scale); + } + // Default: 2x Retina = 326 DPI. + return 326; +} + +void iOSWindow::ApplyNewFullscreen() { + // iOS is always fullscreen. Update status bar/home indicator visibility. + if (view_controller_) { + [view_controller_ setNeedsStatusBarAppearanceUpdate]; + [view_controller_ setNeedsUpdateOfHomeIndicatorAutoHidden]; + } +} + +std::unique_ptr iOSWindow::CreateSurfaceImpl(Surface::TypeFlags allowed_types) { + if (!view_) { + return nullptr; + } + if (allowed_types & (1 << Surface::kTypeIndex_iOSUIView)) { + return std::make_unique(view_); + } + return nullptr; +} + +void iOSWindow::RequestPaintImpl() { + // Painting is driven by CADisplayLink while the window is visible. Don't + // enqueue extra immediate paints in that mode - they can pile up under fast + // guest refresh and race CoreAnimation drawable lifetime tracking. + if (display_link_driving_paint_.load(std::memory_order_acquire)) { + return; + } + if (paint_request_pending_.exchange(true, std::memory_order_acq_rel)) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (!paint_request_pending_.exchange(false, std::memory_order_acq_rel)) { + return; + } + OnPaint(); + }); +} + +void iOSWindow::ApplyDisplayLinkFrameRateRangeFromUIThread() { + if (!display_link_) { + return; + } + UIScreen* screen = view_ && view_.window ? view_.window.screen : UIScreen.mainScreen; + const float screen_max_fps = + screen ? static_cast(screen.maximumFramesPerSecond) : 60.0f; + const float maximum_fps = std::max(30.0f, screen_max_fps); + const float capped_fps = std::min(60.0f, maximum_fps); + const float preferred_fps = + guest_display_refresh_capped_ ? capped_fps : maximum_fps; + const float maximum_range_fps = + guest_display_refresh_capped_ ? capped_fps : maximum_fps; + display_link_.preferredFrameRateRange = + CAFrameRateRangeMake(capped_fps, maximum_range_fps, preferred_fps); +} + +} // namespace ui +} // namespace xe + +// Helper Objective-C class for display link callback. +// Must be at global scope (ObjC declarations cannot appear inside C++ +// namespaces). +@interface XeniaDisplayLinkTarget : NSObject { + xe::ui::iOSWindow* _window; + CADisplayLink* _displayLink; +} +- (instancetype)initWithWindow:(xe::ui::iOSWindow*)window; +- (void)displayLinkFired:(CADisplayLink*)link; +- (void)setDisplayLink:(CADisplayLink*)link; +- (void)startObservingLifecycle; +- (void)stopObservingLifecycle; +@end + +@implementation XeniaDisplayLinkTarget +- (instancetype)initWithWindow:(xe::ui::iOSWindow*)window { + if (self = [super init]) { + _window = window; + _displayLink = nil; + } + return self; +} + +- (void)setDisplayLink:(CADisplayLink*)link { + _displayLink = link; +} + +- (void)displayLinkFired:(CADisplayLink*)link { + if (_window) { + _window->TriggerPaint(); + } +} + +- (void)startObservingLifecycle { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillResignActive:) + name:UIApplicationWillResignActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; +} + +- (void)stopObservingLifecycle { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)appWillResignActive:(NSNotification*)notification { + // Transient resign-active transitions can happen while remaining visible + // (for example around debugger / system overlays), so don't pause here. + XELOGI("iOS lifecycle: app resigned active"); +} + +- (void)appDidBecomeActive:(NSNotification*)notification { + // Resume the display link when the app returns to foreground. + if (_displayLink) { + _displayLink.paused = NO; + } + // Notify the window of potential size changes (e.g. split-view transitions). + if (_window) { + _window->HandleSizeChange(); + } + XELOGI("iOS lifecycle: display link resumed (app became active)"); +} + +- (void)appDidEnterBackground:(NSNotification*)notification { + // Pause rendering only once the app is actually backgrounded. + if (_displayLink) { + _displayLink.paused = YES; + } + XELOGI("iOS lifecycle: display link paused (app entered background)"); +} + +- (void)appWillEnterForeground:(NSNotification*)notification { + XELOGI("iOS lifecycle: app will enter foreground"); +} + +- (void)dealloc { + [self stopObservingLifecycle]; + [super dealloc]; +} +@end + +static XeniaDisplayLinkTarget* g_display_link_target = nil; + +namespace xe { +namespace ui { + +void iOSWindow::SetupDisplayLink() { + if (display_link_) { + return; + } + + g_display_link_target = [[XeniaDisplayLinkTarget alloc] initWithWindow:this]; + display_link_ = [CADisplayLink displayLinkWithTarget:g_display_link_target + selector:@selector(displayLinkFired:)]; + [g_display_link_target setDisplayLink:display_link_]; + // Keep presenter pacing display-link driven, with the preferred refresh rate + // following the guest display cap toggle. + ApplyDisplayLinkFrameRateRangeFromUIThread(); + [display_link_ addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + display_link_driving_paint_.store(true, std::memory_order_release); + + // Observe iOS lifecycle events to pause/resume display link. + [g_display_link_target startObservingLifecycle]; +} + +void iOSWindow::TeardownDisplayLink() { + if (g_display_link_target) { + [g_display_link_target stopObservingLifecycle]; + } + if (display_link_) { + [display_link_ invalidate]; + display_link_ = nil; + } + display_link_driving_paint_.store(false, std::memory_order_release); + paint_request_pending_.store(false, std::memory_order_release); + g_display_link_target = nil; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/launcher/ios_compat_cache.h b/src/xenia/ui/ios/launcher/ios_compat_cache.h new file mode 100644 index 000000000..4f60ee1b9 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_cache.h @@ -0,0 +1,21 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_CACHE_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_CACHE_H_ + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +NSString* xe_compat_cache_path(void); +NSDictionary* xe_parse_compat_json(NSData* data); +NSArray* xe_parse_compat_games_array(NSData* data); +NSArray* xe_merge_remote_compat_games_with_cached_submissions(NSArray* remote_games, + NSArray* cached_games); + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_CACHE_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_cache.mm b/src/xenia/ui/ios/launcher/ios_compat_cache.mm new file mode 100644 index 000000000..bc53d79d1 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_cache.mm @@ -0,0 +1,457 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + + +#import "xenia/ui/ios/launcher/ios_compat_cache.h" + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +NSString* xe_discussion_cache_path(uint32_t title_id) { + NSString* caches = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + return [caches + stringByAppendingPathComponent:[NSString stringWithFormat:@"discussion-%08X.json", title_id]]; +} + +NSString* xe_compat_cache_path(void) { + NSString* caches = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + return [caches stringByAppendingPathComponent:@"compat-data.json"]; +} + +static NSString* xe_current_iso_date_string(void) { + static NSDateFormatter* formatter = nil; + static dispatch_once_t once_token; + dispatch_once(&once_token, ^{ + formatter = [[NSDateFormatter alloc] init]; + formatter.dateFormat = @"yyyy-MM-dd"; + formatter.locale = [[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"] autorelease]; + formatter.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + }); + return [formatter stringFromDate:[NSDate date]]; +} + +static NSInteger xe_issue_number_from_url(NSString* issue_url) { + NSURL* url = [NSURL URLWithString:issue_url]; + NSString* last_component = url.pathComponents.lastObject; + NSInteger issue_number = last_component.integerValue; + return issue_number > 0 ? issue_number : 0; +} + +NSDictionary* xe_build_local_compat_report(NSString* device, NSString* device_machine, + NSString* os_version, NSString* status, NSString* perf, + NSString* notes, NSDictionary* build_info) { + NSMutableDictionary* report = [NSMutableDictionary + dictionaryWithObjectsAndKeys:device ?: @"Unknown", @"device", @"ios", @"platform", + os_version ?: @"", @"osVersion", @"arm64", @"arch", @"msl", + @"gpuBackend", status ?: @"nothing", @"status", perf ?: @"n/a", + @"perf", xe_current_iso_date_string(), @"date", notes ?: @"", + @"notes", @"app", @"source", nil]; + if ([device_machine isKindOfClass:[NSString class]] && device_machine.length > 0) { + report[@"deviceMachine"] = device_machine; + } + NSDictionary* sanitized_build = xe_compat_build_info_from_entry(@{@"build" : build_info ?: @{}}); + if (sanitized_build) { + report[@"build"] = sanitized_build; + } + return report; +} + +NSDictionary* xe_update_cached_compat_entry_for_submission(uint32_t title_id, NSString* title, + NSDictionary* report, + NSString* issue_url) { + NSString* cache_path = xe_compat_cache_path(); + NSData* cached_data = [NSData dataWithContentsOfFile:cache_path]; + NSMutableArray* entries = [NSMutableArray array]; + if (cached_data.length > 0) { + id parsed = [NSJSONSerialization JSONObjectWithData:cached_data options:0 error:nil]; + if ([parsed isKindOfClass:[NSArray class]]) { + [entries addObjectsFromArray:(NSArray*)parsed]; + } + } + + NSString* title_id_string = XEFormatTitleIDHexUpper(title_id); + NSUInteger existing_index = NSNotFound; + NSDictionary* current_entry = nil; + for (NSUInteger i = 0; i < entries.count; ++i) { + id item = entries[i]; + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString* cached_title_id = xe_string_from_object(item[@"titleId"]); + if ([cached_title_id caseInsensitiveCompare:title_id_string] == NSOrderedSame) { + existing_index = i; + current_entry = item; + break; + } + } + + NSMutableArray* reports = [NSMutableArray arrayWithObject:report]; + NSArray* existing_reports = + [current_entry[@"reports"] isKindOfClass:[NSArray class]] ? current_entry[@"reports"] : nil; + if (existing_reports) { + [reports addObjectsFromArray:existing_reports]; + } + + NSMutableDictionary* next_entry = + current_entry ? [[current_entry mutableCopy] autorelease] : [NSMutableDictionary dictionary]; + next_entry[@"titleId"] = title_id_string; + next_entry[@"title"] = title ?: title_id_string; + next_entry[@"status"] = xe_string_from_object(report[@"status"]) ?: @"nothing"; + next_entry[@"perf"] = xe_string_from_object(report[@"perf"]) ?: @"n/a"; + next_entry[@"notes"] = xe_string_from_object(report[@"notes"]) ?: @""; + next_entry[@"updatedAt"] = xe_string_from_object(report[@"date"]) ?: xe_current_iso_date_string(); + next_entry[@"reports"] = reports; + + NSString* device = xe_string_from_object(report[@"device"]); + NSString* device_machine = xe_string_from_object(report[@"deviceMachine"]); + NSString* platform = xe_string_from_object(report[@"platform"]); + NSString* os_version = xe_string_from_object(report[@"osVersion"]); + NSString* arch = xe_string_from_object(report[@"arch"]); + NSString* gpu_backend = xe_string_from_object(report[@"gpuBackend"]); + + if (device.length > 0) { + next_entry[@"device"] = device; + } + if (device_machine.length > 0) { + next_entry[@"deviceMachine"] = device_machine; + } + if (platform.length > 0) { + next_entry[@"platform"] = platform; + } + if (os_version.length > 0) { + next_entry[@"osVersion"] = os_version; + } + if (arch.length > 0) { + next_entry[@"arch"] = arch; + } + if (gpu_backend.length > 0) { + next_entry[@"gpuBackend"] = gpu_backend; + } + + NSMutableDictionary* last_report = [NSMutableDictionary dictionary]; + if (device.length > 0) { + last_report[@"device"] = device; + } + if (platform.length > 0) { + last_report[@"platform"] = platform; + } + if (os_version.length > 0) { + last_report[@"osVersion"] = os_version; + } + if (arch.length > 0) { + last_report[@"arch"] = arch; + } + if (gpu_backend.length > 0) { + last_report[@"gpuBackend"] = gpu_backend; + } + if (last_report.count > 0) { + next_entry[@"lastReport"] = last_report; + } + + NSMutableArray* platforms = + [NSMutableArray arrayWithArray:[current_entry[@"platforms"] isKindOfClass:[NSArray class]] + ? current_entry[@"platforms"] + : @[]]; + if (platform.length > 0 && ![platforms containsObject:platform]) { + [platforms addObject:platform]; + } + if (platforms.count > 0) { + next_entry[@"platforms"] = platforms; + } + + NSString* next_issue_url = + issue_url.length > 0 ? issue_url : xe_string_from_object(current_entry[@"issueUrl"]); + if (next_issue_url.length > 0) { + next_entry[@"issueUrl"] = next_issue_url; + NSInteger issue_number = xe_issue_number_from_url(next_issue_url); + if (issue_number > 0) { + next_entry[@"issueNumber"] = @(issue_number); + } + } + + if (existing_index != NSNotFound) { + entries[existing_index] = next_entry; + } else { + [entries addObject:next_entry]; + } + + if ([NSJSONSerialization isValidJSONObject:entries]) { + NSError* write_error = nil; + NSData* data = [NSJSONSerialization dataWithJSONObject:entries + options:NSJSONWritingPrettyPrinted + error:&write_error]; + if (data && !write_error) { + NSMutableData* writable = [data mutableCopy]; + static const char newline = '\n'; + [writable appendBytes:&newline length:1]; + [writable writeToFile:cache_path atomically:YES]; + [writable release]; + } + } + return next_entry; +} + +NSDictionary* xe_cache_discussion_snapshot_for_submission(uint32_t title_id, + NSDictionary* compat_entry) { + if (!compat_entry) { + return nil; + } + + NSMutableDictionary* snapshot = [NSMutableDictionary dictionary]; + NSArray* reports = + [compat_entry[@"reports"] isKindOfClass:[NSArray class]] ? compat_entry[@"reports"] : nil; + if (reports.count > 0) { + snapshot[@"reports"] = reports; + } + NSString* issue_url = xe_string_from_object(compat_entry[@"issueUrl"]); + if (issue_url.length > 0) { + snapshot[@"issueUrl"] = issue_url; + NSInteger issue_number = xe_issue_number_from_url(issue_url); + if (issue_number > 0) { + snapshot[@"issueNumber"] = @(issue_number); + } + } else if ([compat_entry[@"issueNumber"] isKindOfClass:[NSNumber class]]) { + snapshot[@"issueNumber"] = compat_entry[@"issueNumber"]; + } + + if (snapshot.count == 0) { + return nil; + } + + if ([NSJSONSerialization isValidJSONObject:snapshot]) { + NSError* write_error = nil; + NSData* data = [NSJSONSerialization dataWithJSONObject:snapshot + options:NSJSONWritingPrettyPrinted + error:&write_error]; + if (data && !write_error) { + NSMutableData* writable = [data mutableCopy]; + static const char newline = '\n'; + [writable appendBytes:&newline length:1]; + [writable writeToFile:xe_discussion_cache_path(title_id) atomically:YES]; + [writable release]; + } + } + return snapshot; +} + +NSDictionary* xe_parse_compat_json(NSData* data) { + if (!data || data.length == 0) { + return nil; + } + NSError* error = nil; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (error || ![parsed isKindOfClass:[NSArray class]]) { + return nil; + } + NSMutableDictionary* dict = [NSMutableDictionary dictionaryWithCapacity:[parsed count]]; + for (NSDictionary* game in parsed) { + if (![game isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString* title_id = game[@"titleId"]; + if ([title_id isKindOfClass:[NSString class]] && title_id.length > 0) { + [dict setObject:game forKey:[title_id uppercaseString]]; + } + } + return dict; +} + +NSDictionary* xe_load_cached_compat_data(void) { + NSData* cached = [NSData dataWithContentsOfFile:xe_compat_cache_path()]; + if (cached.length == 0) { + return nil; + } + return xe_parse_compat_json(cached); +} + +NSArray* xe_parse_compat_games_array(NSData* data) { + if (!data || data.length == 0) { + return nil; + } + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSArray class]] ? (NSArray*)parsed : nil; +} + +static NSString* xe_compat_report_identity(NSDictionary* report) { + if (![report isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSDictionary* build = xe_dictionary_from_object(report[@"build"]); + return [@[ + xe_string_from_object(report[@"date"]) ?: @"", + xe_string_from_object(report[@"platform"]) ?: @"", + xe_string_from_object(report[@"device"]) ?: @"", + xe_string_from_object(report[@"status"]) ?: @"", xe_string_from_object(report[@"perf"]) ?: @"", + xe_string_from_object(report[@"notes"]) ?: @"", xe_string_from_object(build[@"buildId"]) ?: @"", + xe_string_from_object(build[@"buildNumber"]) ?: @"", + xe_string_from_object(build[@"commitShort"]) ?: @"" + ] componentsJoinedByString:@"|"]; +} + +static NSArray* xe_pending_cached_app_reports(NSDictionary* cached_entry, + NSDictionary* remote_entry) { + NSArray* cached_reports = + [cached_entry[@"reports"] isKindOfClass:[NSArray class]] ? cached_entry[@"reports"] : nil; + if (cached_reports.count == 0) { + return @[]; + } + + NSMutableSet* remote_identities = [NSMutableSet set]; + NSArray* remote_reports = + [remote_entry[@"reports"] isKindOfClass:[NSArray class]] ? remote_entry[@"reports"] : nil; + for (id remote_report in remote_reports) { + NSString* identity = xe_compat_report_identity(remote_report); + if (identity.length > 0) { + [remote_identities addObject:identity]; + } + } + + NSMutableArray* pending_reports = [NSMutableArray array]; + for (id cached_report in cached_reports) { + if (![cached_report isKindOfClass:[NSDictionary class]]) { + continue; + } + if (![xe_string_from_object(cached_report[@"source"]) isEqualToString:@"app"]) { + continue; + } + NSString* cached_identity = xe_compat_report_identity(cached_report); + if (cached_identity.length == 0 || [remote_identities containsObject:cached_identity]) { + continue; + } + [pending_reports addObject:cached_report]; + } + + return pending_reports; +} + +static BOOL xe_cached_entry_has_pending_app_report(NSDictionary* cached_entry, + NSDictionary* remote_entry) { + return xe_pending_cached_app_reports(cached_entry, remote_entry).count > 0; +} + +static NSDictionary* xe_merge_remote_compat_entry_with_cached_submission( + NSDictionary* remote_entry, NSDictionary* cached_entry) { + NSMutableDictionary* merged_entry = [[remote_entry mutableCopy] autorelease]; + + NSArray* pending_reports = xe_pending_cached_app_reports(cached_entry, remote_entry); + NSArray* remote_reports = + [remote_entry[@"reports"] isKindOfClass:[NSArray class]] ? remote_entry[@"reports"] : nil; + NSMutableArray* merged_reports = + [NSMutableArray arrayWithCapacity:remote_reports.count + pending_reports.count]; + if (pending_reports.count > 0) { + [merged_reports addObjectsFromArray:pending_reports]; + } + if (remote_reports.count > 0) { + [merged_reports addObjectsFromArray:remote_reports]; + } + if (merged_reports.count > 0) { + merged_entry[@"reports"] = merged_reports; + } + + for (NSString* key in @[ + @"status", @"perf", @"notes", @"updatedAt", @"device", @"deviceMachine", @"platform", + @"osVersion", @"arch", @"gpuBackend" + ]) { + id value = cached_entry[key]; + if ([value isKindOfClass:[NSString class]] && [value length] > 0) { + merged_entry[key] = value; + } + } + + NSDictionary* cached_last_report = xe_dictionary_from_object(cached_entry[@"lastReport"]); + if (cached_last_report) { + merged_entry[@"lastReport"] = cached_last_report; + } + + NSMutableArray* merged_platforms = + [NSMutableArray arrayWithArray:[remote_entry[@"platforms"] isKindOfClass:[NSArray class]] + ? remote_entry[@"platforms"] + : @[]]; + NSArray* cached_platforms = + [cached_entry[@"platforms"] isKindOfClass:[NSArray class]] ? cached_entry[@"platforms"] : nil; + for (id platform in cached_platforms) { + if ([platform isKindOfClass:[NSString class]] && ![merged_platforms containsObject:platform]) { + [merged_platforms addObject:platform]; + } + } + if (merged_platforms.count > 0) { + merged_entry[@"platforms"] = merged_platforms; + } + + NSString* remote_title = xe_string_from_object(remote_entry[@"title"]); + NSString* cached_title = xe_string_from_object(cached_entry[@"title"]); + if (remote_title.length == 0 && cached_title.length > 0) { + merged_entry[@"title"] = cached_title; + } + + NSString* remote_issue_url = xe_string_from_object(remote_entry[@"issueUrl"]); + NSString* cached_issue_url = xe_string_from_object(cached_entry[@"issueUrl"]); + if (remote_issue_url.length == 0 && cached_issue_url.length > 0) { + merged_entry[@"issueUrl"] = cached_issue_url; + } + if (![merged_entry[@"issueNumber"] isKindOfClass:[NSNumber class]] && + [cached_entry[@"issueNumber"] isKindOfClass:[NSNumber class]]) { + merged_entry[@"issueNumber"] = cached_entry[@"issueNumber"]; + } + + return merged_entry; +} + +NSArray* xe_merge_remote_compat_games_with_cached_submissions(NSArray* remote_games, + NSArray* cached_games) { + if (remote_games.count == 0) { + return cached_games.count > 0 ? cached_games : remote_games; + } + if (cached_games.count == 0) { + return remote_games; + } + + NSMutableArray* merged = [NSMutableArray arrayWithArray:remote_games]; + NSMutableDictionary* remote_indexes = + [NSMutableDictionary dictionaryWithCapacity:remote_games.count]; + for (NSUInteger i = 0; i < remote_games.count; ++i) { + id item = remote_games[i]; + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString* title_id = xe_string_from_object(item[@"titleId"]); + if (title_id.length > 0) { + remote_indexes[[title_id uppercaseString]] = @(i); + } + } + + for (id item in cached_games) { + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary* cached_entry = (NSDictionary*)item; + NSString* title_id = [xe_string_from_object(cached_entry[@"titleId"]) uppercaseString]; + if (title_id.length == 0) { + continue; + } + + NSNumber* remote_index = remote_indexes[title_id]; + NSDictionary* remote_entry = + remote_index ? xe_dictionary_from_object(merged[remote_index.unsignedIntegerValue]) : nil; + if (!remote_entry) { + if (xe_cached_entry_has_pending_app_report(cached_entry, nil)) { + [merged addObject:cached_entry]; + } + continue; + } + + if (xe_cached_entry_has_pending_app_report(cached_entry, remote_entry)) { + merged[remote_index.unsignedIntegerValue] = + xe_merge_remote_compat_entry_with_cached_submission(remote_entry, cached_entry); + } + } + + return merged; +} diff --git a/src/xenia/ui/ios/launcher/ios_compat_data.h b/src/xenia/ui/ios/launcher/ios_compat_data.h new file mode 100644 index 000000000..e4f358f80 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_data.h @@ -0,0 +1,88 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_COMPAT_DATA_H_ +#define XENIA_UI_IOS_COMPAT_DATA_H_ + +#import + +#include + +// Compatibility data layer for the iOS launcher. +// +// Loads and caches the public games.json from xenios.jp / the Cloudflare +// worker, merges in local self-built reports, and exposes formatting helpers +// for status / perf badges, build labels, hero heights and platform strings +// used by the compatibility hero card and report sheets. +// +// On-disk caches: +// Library/Caches/compat-data.json +// Library/Caches/discussion-{title_id_hex_upper}.json +// +// Posts kXeniaCompatDataDidUpdateNotification when the cached games list is +// updated, and kXeniaDiscussionDidUpdateNotification after a discussion +// snapshot is written. + +extern NSString* const kXeniaCompatDataDidUpdateNotification; +extern NSString* const kXeniaDiscussionDidUpdateNotification; + +// JSON utility helpers (used widely across the compat layer). +NSString* xe_string_from_object(id value); +NSDictionary* xe_dictionary_from_object(id value); + +// Status / perf appearance + display. +UIColor* xe_compat_status_color(NSString* status); +NSString* xe_compat_status_label(NSString* status); +UIColor* xe_compat_perf_color(NSString* perf); +NSString* xe_compat_perf_label(NSString* perf); +NSArray* xe_compat_statuses(void); +NSArray* xe_compat_status_labels(void); +NSArray* xe_compat_perfs(void); +NSArray* xe_compat_perf_labels(void); + +// Channel labels used in the build pill. +UIColor* xe_compat_channel_color(NSString* channel); +NSString* xe_compat_channel_label(NSString* channel); + +// Build labels. +NSString* xe_compat_build_label(NSDictionary* build_info); +NSString* xe_user_facing_build_label(NSDictionary* build_info); +NSDictionary* xe_compat_build_info_from_entry(NSDictionary* entry); +NSDictionary* xe_current_compat_report_build_info(void); + +// Date / platform formatting. +NSString* xe_format_iso_date(NSString* iso); +NSString* xe_platform_display_text(NSString* platform, NSString* os_version); + +// Layout helper for the compatibility hero card. +CGFloat xe_compat_hero_height_for_width(CGFloat width, UIImage* background_art); + +// Summary derivation – mirrors xenios.jp's release-summary semantics for iOS. +NSDictionary* xe_release_summary_from_compat_info(NSDictionary* compat_info); +NSDictionary* xe_ios_public_release_summary_from_compat_info(NSDictionary* compat_info); +NSDictionary* xe_preferred_summary_from_compat_info(NSDictionary* compat_info); + +// Cache + discussion paths. +NSString* xe_discussion_cache_path(uint32_t title_id); + +// Building + persisting reports for compatibility submissions. +NSDictionary* xe_build_local_compat_report(NSString* device, NSString* device_machine, + NSString* os_version, NSString* status, NSString* perf, + NSString* notes, NSDictionary* build_info); +NSDictionary* xe_update_cached_compat_entry_for_submission(uint32_t title_id, NSString* title, + NSDictionary* report, + NSString* issue_url); +NSDictionary* xe_cache_discussion_snapshot_for_submission(uint32_t title_id, + NSDictionary* compat_entry); + +// Cache load / fetch. +NSDictionary* xe_load_cached_compat_data(void); +void xe_fetch_compat_data(void (^completion)(NSDictionary* _Nullable data)); + +#endif // XENIA_UI_IOS_COMPAT_DATA_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_data.mm b/src/xenia/ui/ios/launcher/ios_compat_data.mm new file mode 100644 index 000000000..f19da18c1 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_data.mm @@ -0,0 +1,19 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +#import "xenia/ui/ios/launcher/ios_compat_cache.h" +#import "xenia/ui/ios/launcher/ios_compat_fetch.h" +#import "xenia/ui/ios/launcher/ios_compat_formatting.h" +#import "xenia/ui/ios/launcher/ios_compat_summary.h" + +NSString* const kXeniaCompatDataDidUpdateNotification = @"XeniaCompatDataDidUpdateNotification"; +NSString* const kXeniaDiscussionDidUpdateNotification = @"XeniaDiscussionDidUpdateNotification"; diff --git a/src/xenia/ui/ios/launcher/ios_compat_fetch.h b/src/xenia/ui/ios/launcher/ios_compat_fetch.h new file mode 100644 index 000000000..85786c356 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_fetch.h @@ -0,0 +1,15 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FETCH_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FETCH_H_ + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FETCH_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_fetch.mm b/src/xenia/ui/ios/launcher/ios_compat_fetch.mm new file mode 100644 index 000000000..739ac4acb --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_fetch.mm @@ -0,0 +1,126 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + + +#import "xenia/ui/ios/launcher/ios_compat_fetch.h" + +#import "xenia/ui/ios/launcher/ios_compat_cache.h" + +static NSURLSession* xe_compat_url_session(void) { + static NSURLSession* session = nil; + static dispatch_once_t once_token; + dispatch_once(&once_token, ^{ + NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration]; + if (@available(iOS 11.0, *)) { + config.waitsForConnectivity = YES; + } + config.timeoutIntervalForRequest = 20.0; + config.timeoutIntervalForResource = 45.0; + session = [[NSURLSession sessionWithConfiguration:config] retain]; + }); + return session; +} +static void xe_fetch_compat_data_from_urls(NSArray* url_strings, NSUInteger index, + NSData* cached, + void (^completion)(NSDictionary* _Nullable data)) { + if (index >= [url_strings count]) { + return; + } + + NSURL* url = [NSURL URLWithString:url_strings[index]]; + if (!url) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + + NSURLSessionDataTask* task = [xe_compat_url_session() + dataTaskWithURL:url + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + if (error || data.length == 0) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + + NSHTTPURLResponse* http = (NSHTTPURLResponse*)response; + if (![http isKindOfClass:[NSHTTPURLResponse class]] || http.statusCode != 200) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + + NSArray* remote_games = xe_parse_compat_games_array(data); + if (!remote_games) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + if (remote_games.count == 0) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + + NSArray* cached_games = xe_parse_compat_games_array(cached); + NSArray* merged_games = cached_games ? xe_merge_remote_compat_games_with_cached_submissions( + remote_games, cached_games) + : remote_games; + BOOL preserved_cached_submission = ![merged_games isEqualToArray:remote_games]; + NSData* data_to_store = data; + if (preserved_cached_submission) { + NSError* serialize_error = nil; + NSData* serialized = [NSJSONSerialization dataWithJSONObject:merged_games + options:NSJSONWritingPrettyPrinted + error:&serialize_error]; + if (!serialized || serialize_error) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + NSMutableData* writable = [serialized mutableCopy]; + static const char newline = '\n'; + [writable appendBytes:&newline length:1]; + data_to_store = [writable autorelease]; + } + + NSDictionary* result = xe_parse_compat_json(data_to_store); + if (!result) { + xe_fetch_compat_data_from_urls(url_strings, index + 1, cached, completion); + return; + } + + if (cached.length == 0 || ![cached isEqualToData:data_to_store]) { + [data_to_store writeToFile:xe_compat_cache_path() atomically:YES]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) { + completion(result); + } + }); + } + }]; + [task resume]; +} + +void xe_fetch_compat_data(void (^completion)(NSDictionary* _Nullable data)) { + NSData* cached = [NSData dataWithContentsOfFile:xe_compat_cache_path()]; + if (cached.length > 0) { + NSDictionary* cached_result = xe_parse_compat_json(cached); + if (cached_result) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) { + completion(cached_result); + } + }); + } + } + + xe_fetch_compat_data_from_urls( + @[ + @"https://xenios-compat-api.xenios.workers.dev/games", + // The static site export can lag behind freshly submitted self-built reports, + // so use it only if the live API is unavailable. + @"https://xenios.jp/compatibility/data.json", + ], + 0, cached, completion); +} diff --git a/src/xenia/ui/ios/launcher/ios_compat_formatting.h b/src/xenia/ui/ios/launcher/ios_compat_formatting.h new file mode 100644 index 000000000..5ae44d4d2 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_formatting.h @@ -0,0 +1,17 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FORMATTING_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FORMATTING_H_ + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +NSInteger xe_compat_status_rank(NSString* status); + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_FORMATTING_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_formatting.mm b/src/xenia/ui/ios/launcher/ios_compat_formatting.mm new file mode 100644 index 000000000..41821da5c --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_formatting.mm @@ -0,0 +1,463 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + + +#import "xenia/ui/ios/launcher/ios_compat_formatting.h" + +#include + +// Autogenerated by `xb premake`. +#include "build/version.h" + +#ifndef XE_BUILD_VERSION +#define XE_BUILD_VERSION "1.0.0" +#endif + +#ifndef XE_BUILD_NUMBER +#define XE_BUILD_NUMBER "0" +#endif + +#ifndef XE_BUILD_COMMIT_SHORT +#define XE_BUILD_COMMIT_SHORT "" +#endif + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +// Compatibility taxonomy: status / perf / channel / release-stage all share +// the same "code → label / color / rank" lookup shape, so a single struct + +// generic FindByCode() drive every accessor below. `hex_color == 0` means +// render with [XeniaTheme textMuted]; `rank` is only meaningful for +// statuses (higher = "more playable"). +struct CompatTaxonomyEntry { + NSString* code; + NSString* label; + uint32_t hex_color; + NSInteger rank; +}; + +const CompatTaxonomyEntry kCompatStatuses[] = { + {@"playable", @"Playable", 0x34d399, 4}, {@"ingame", @"In-Game", 0x60a5fa, 3}, + {@"intro", @"Intro", 0xfbbf24, 2}, {@"loads", @"Loads", 0xfb923c, 1}, + {@"nothing", @"Nothing", 0xf87171, 0}, +}; + +const CompatTaxonomyEntry kCompatStatusUntested = {@"untested", @"Untested", 0, 0}; + +const CompatTaxonomyEntry kCompatPerfs[] = { + {@"great", @"Great", 0x34d399, 0}, + {@"ok", @"Okay", 0x60a5fa, 0}, + {@"poor", @"Poor", 0xfbbf24, 0}, + {@"n/a", @"N/A", 0, 0}, +}; + +UIColor* ColorFromTaxonomy(const CompatTaxonomyEntry& entry) { + return entry.hex_color ? XEColorFromHexRGB(entry.hex_color) : [XeniaTheme textMuted]; +} + +const CompatTaxonomyEntry* FindByCode(const CompatTaxonomyEntry* entries, size_t count, + NSString* code) { + for (size_t i = 0; i < count; ++i) { + if ([entries[i].code isEqualToString:code]) { + return &entries[i]; + } + } + return nullptr; +} + +const CompatTaxonomyEntry* FindCompatStatus(NSString* code) { + if (const CompatTaxonomyEntry* entry = + FindByCode(kCompatStatuses, std::size(kCompatStatuses), code)) { + return entry; + } + if ([code isEqualToString:kCompatStatusUntested.code]) { + return &kCompatStatusUntested; + } + return nullptr; +} + +const CompatTaxonomyEntry* FindCompatPerf(NSString* code) { + return FindByCode(kCompatPerfs, std::size(kCompatPerfs), code); +} + +} // namespace + +UIColor* xe_compat_status_color(NSString* status) { + const CompatTaxonomyEntry* entry = FindCompatStatus(status); + return entry ? ColorFromTaxonomy(*entry) : [XeniaTheme textMuted]; +} + +NSString* xe_compat_status_label(NSString* status) { + const CompatTaxonomyEntry* entry = FindCompatStatus(status); + return entry ? entry->label : @"Unknown"; +} + +UIColor* xe_compat_perf_color(NSString* perf) { + const CompatTaxonomyEntry* entry = FindCompatPerf(perf); + return entry ? ColorFromTaxonomy(*entry) : [XeniaTheme textMuted]; +} + +NSString* xe_compat_perf_label(NSString* perf) { + const CompatTaxonomyEntry* entry = FindCompatPerf(perf); + return entry ? entry->label : @"Unknown"; +} + +NSString* xe_format_iso_date(NSString* iso) { + if (!iso || iso.length < 10) { + return iso ?: @""; + } + static NSDateFormatter* parser = nil; + static NSDateFormatter* date_only_parser = nil; + static NSDateFormatter* formatter = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + parser = [[NSDateFormatter alloc] init]; + parser.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss"; + parser.locale = [[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"] autorelease]; + date_only_parser = [[NSDateFormatter alloc] init]; + date_only_parser.dateFormat = @"yyyy-MM-dd"; + date_only_parser.locale = + [[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"] autorelease]; + formatter = [[NSDateFormatter alloc] init]; + formatter.dateStyle = NSDateFormatterMediumStyle; + formatter.timeStyle = NSDateFormatterNoStyle; + }); + + NSString* trimmed = [iso substringToIndex:MIN(iso.length, (NSUInteger)19)]; + NSDate* date = [parser dateFromString:trimmed]; + if (!date) { + date = [date_only_parser dateFromString:[iso substringToIndex:MIN(iso.length, (NSUInteger)10)]]; + } + return date ? [formatter stringFromDate:date] + : [iso substringToIndex:MIN(iso.length, (NSUInteger)10)]; +} + +NSString* xe_platform_display_text(NSString* platform, NSString* os_version) { + if (![platform isKindOfClass:[NSString class]] || platform.length == 0) { + return @""; + } + NSString* platform_name = [platform isEqualToString:@"ios"] ? @"iOS" + : [platform isEqualToString:@"macos"] ? @"macOS" + : platform; + if (![os_version isKindOfClass:[NSString class]] || os_version.length == 0) { + return platform_name; + } + return [NSString stringWithFormat:@"%@ %@", platform_name, os_version]; +} + +NSString* xe_string_from_object(id value) { + return [value isKindOfClass:[NSString class]] ? (NSString*)value : nil; +} + +NSDictionary* xe_dictionary_from_object(id value) { + return [value isKindOfClass:[NSDictionary class]] ? (NSDictionary*)value : nil; +} + +NSInteger xe_compat_status_rank(NSString* status) { + const CompatTaxonomyEntry* entry = FindCompatStatus(status); + return entry ? entry->rank : 0; +} + +namespace { + +const CompatTaxonomyEntry kCompatChannels[] = { + {@"release", @"Release", 0x34d399, 0}, + {@"preview", @"Preview", 0x60a5fa, 0}, + {@"self-built", @"Self-Built", 0, 0}, +}; + +const CompatTaxonomyEntry kReleaseStages[] = { + {@"alpha", @"Alpha", 0, 0}, + {@"beta", @"Beta", 0, 0}, + {@"rc", @"RC", 0, 0}, +}; + +} // namespace + +UIColor* xe_compat_channel_color(NSString* channel) { + const CompatTaxonomyEntry* entry = + FindByCode(kCompatChannels, std::size(kCompatChannels), channel); + if (entry && entry->hex_color) { + return ColorFromTaxonomy(*entry); + } + return [XeniaTheme textMuted]; +} + +NSString* xe_compat_channel_label(NSString* channel) { + const CompatTaxonomyEntry* entry = + FindByCode(kCompatChannels, std::size(kCompatChannels), channel); + return entry ? entry->label : @"Unknown"; +} + +static NSString* xe_release_stage_display_label(NSString* stage) { + const CompatTaxonomyEntry* entry = FindByCode(kReleaseStages, std::size(kReleaseStages), stage); + return entry ? entry->label : nil; +} + +static NSString* xe_build_version_label(NSDictionary* build_info) { + NSString* app_version = xe_string_from_object(build_info[@"appVersion"]); + NSString* build_number = xe_string_from_object(build_info[@"buildNumber"]); + if (app_version.length > 0 && build_number.length > 0) { + return [NSString stringWithFormat:@"%@-%@", app_version, build_number]; + } + if (app_version.length > 0) { + return app_version; + } + return nil; +} + +NSString* xe_compat_build_label(NSDictionary* build_info) { + NSString* channel = xe_string_from_object(build_info[@"channel"]); + NSString* commit_short = xe_string_from_object(build_info[@"commitShort"]); + if ([channel isEqualToString:@"self-built"] && commit_short.length > 0) { + return commit_short; + } + + NSString* version_label = xe_build_version_label(build_info); + if (version_label.length > 0) { + return version_label; + } + NSString* build_number = xe_string_from_object(build_info[@"buildNumber"]); + if (build_number.length > 0) { + return [NSString stringWithFormat:@"Build %@", build_number]; + } + NSString* build_id = xe_string_from_object(build_info[@"buildId"]); + return build_id.length > 0 ? build_id : nil; +} + +NSString* xe_user_facing_build_label(NSDictionary* build_info) { + if (!build_info) { + return @"Unknown Build"; + } + + NSString* channel = xe_string_from_object(build_info[@"channel"]); + NSString* stage_label = + xe_release_stage_display_label(xe_string_from_object(build_info[@"stage"])); + NSString* version_label = xe_build_version_label(build_info); + NSString* commit_short = xe_string_from_object(build_info[@"commitShort"]); + + if ([channel isEqualToString:@"preview"] && version_label.length > 0) { + if (stage_label.length > 0) { + return [NSString stringWithFormat:@"%@ Preview %@", stage_label, version_label]; + } + return [NSString stringWithFormat:@"Preview %@", version_label]; + } + if ([channel isEqualToString:@"release"] && version_label.length > 0) { + if (stage_label.length > 0) { + return [NSString stringWithFormat:@"%@ %@", stage_label, version_label]; + } + return version_label; + } + if (commit_short.length > 0) { + return [NSString stringWithFormat:@"Dev build %@", commit_short]; + } + if (version_label.length > 0) { + return [NSString stringWithFormat:@"Build %@", version_label]; + } + + NSString* build_number = xe_string_from_object(build_info[@"buildNumber"]); + if (build_number.length > 0) { + return [NSString stringWithFormat:@"Build %@", build_number]; + } + + NSString* build_id = xe_string_from_object(build_info[@"buildId"]); + return build_id.length > 0 ? [NSString stringWithFormat:@"Build %@", build_id] : @"Unknown Build"; +} + +static NSString* xe_sanitize_build_component(NSString* value) { + if (![value isKindOfClass:[NSString class]] || value.length == 0) { + return @"unknown"; + } + NSMutableString* sanitized = [NSMutableString stringWithCapacity:value.length]; + NSCharacterSet* alnum = [NSCharacterSet alphanumericCharacterSet]; + BOOL last_was_dash = NO; + NSString* lower = [value lowercaseString]; + for (NSUInteger i = 0; i < lower.length; ++i) { + unichar c = [lower characterAtIndex:i]; + if ([alnum characterIsMember:c]) { + [sanitized appendFormat:@"%C", c]; + last_was_dash = NO; + } else if (!last_was_dash) { + [sanitized appendString:@"-"]; + last_was_dash = YES; + } + } + while ([sanitized hasPrefix:@"-"]) { + [sanitized deleteCharactersInRange:NSMakeRange(0, 1)]; + } + while ([sanitized hasSuffix:@"-"]) { + [sanitized deleteCharactersInRange:NSMakeRange(sanitized.length - 1, 1)]; + } + return sanitized.length > 0 ? sanitized : @"unknown"; +} + +static NSString* xe_make_build_id(NSString* platform, NSString* channel, NSString* app_version, + NSString* build_number, NSString* stage) { + NSString* base = [NSString stringWithFormat:@"%@-%@-%@-%@", xe_sanitize_build_component(platform), + xe_sanitize_build_component(channel), + xe_sanitize_build_component(app_version), + xe_sanitize_build_component(build_number)]; + if ([stage isKindOfClass:[NSString class]] && stage.length > 0 && + ![stage isEqualToString:@"stable"]) { + return [base stringByAppendingFormat:@"-%@", xe_sanitize_build_component(stage)]; + } + return base; +} + +NSDictionary* xe_compat_build_info_from_entry(NSDictionary* entry) { + if (!entry) { + return nil; + } + NSDictionary* nested = xe_dictionary_from_object(entry[@"build"]); + NSString* build_id = xe_string_from_object(nested ? nested[@"buildId"] : entry[@"buildId"]); + NSString* channel = xe_string_from_object(nested ? nested[@"channel"] : entry[@"channel"]); + id official_value = nested ? nested[@"official"] : entry[@"official"]; + NSNumber* official = + [official_value isKindOfClass:[NSNumber class]] ? (NSNumber*)official_value : nil; + NSString* app_version = + xe_string_from_object(nested ? nested[@"appVersion"] : entry[@"appVersion"]); + NSString* build_number = + xe_string_from_object(nested ? nested[@"buildNumber"] : entry[@"buildNumber"]); + NSString* commit_short = + xe_string_from_object(nested ? nested[@"commitShort"] : entry[@"commitShort"]); + NSString* stage = xe_string_from_object(nested ? nested[@"stage"] : entry[@"stage"]); + if (build_id.length == 0 && channel.length == 0 && app_version.length == 0 && + build_number.length == 0 && commit_short.length == 0 && stage.length == 0 && !official) { + return nil; + } + + NSMutableDictionary* build = [NSMutableDictionary dictionary]; + if (build_id.length > 0) { + build[@"buildId"] = build_id; + } + if (channel.length > 0) { + build[@"channel"] = channel; + } + if (official) { + build[@"official"] = official; + } + if (app_version.length > 0) { + build[@"appVersion"] = app_version; + } + if (build_number.length > 0) { + build[@"buildNumber"] = build_number; + } + if (commit_short.length > 0) { + build[@"commitShort"] = commit_short; + } + if (stage.length > 0) { + build[@"stage"] = stage; + } + return build.count > 0 ? build : nil; +} + +NSDictionary* xe_current_compat_report_build_info(void) { + static NSDictionary* build_info = nil; + static dispatch_once_t once_token; + dispatch_once(&once_token, ^{ + NSBundle* bundle = [NSBundle mainBundle]; + NSString* app_version = + xe_string_from_object([bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]); + NSString* build_number = + xe_string_from_object([bundle objectForInfoDictionaryKey:@"CFBundleVersion"]); + NSString* compiled_app_version = [NSString stringWithUTF8String:XE_BUILD_VERSION]; + NSString* compiled_build_number = [NSString stringWithUTF8String:XE_BUILD_NUMBER]; + BOOL uses_placeholder_bundle_version = + (app_version.length == 0 || [app_version isEqualToString:@"1.0"]) && + (build_number.length == 0 || [build_number isEqualToString:@"1"]); + if (uses_placeholder_bundle_version) { + app_version = compiled_app_version.length > 0 ? compiled_app_version : @"1.0.0"; + build_number = compiled_build_number.length > 0 ? compiled_build_number : @"0"; + } else { + if (app_version.length == 0) { + app_version = compiled_app_version.length > 0 ? compiled_app_version : @"1.0.0"; + } + if (build_number.length == 0) { + build_number = compiled_build_number.length > 0 ? compiled_build_number : @"0"; + } + } + + NSString* attested_channel = + xe_string_from_object([bundle objectForInfoDictionaryKey:@"XeniOSBuildChannel"]); + NSString* build_stage = + xe_string_from_object([bundle objectForInfoDictionaryKey:@"XeniOSBuildStage"]); + NSString* attestation_payload = + xe_string_from_object([bundle objectForInfoDictionaryKey:@"XeniOSBuildAttestationPayload"]); + NSString* attestation_signature = xe_string_from_object( + [bundle objectForInfoDictionaryKey:@"XeniOSBuildAttestationSignature"]); + BOOL has_attested_channel = [attested_channel isEqualToString:@"release"] || + [attested_channel isEqualToString:@"preview"]; + BOOL has_attestation = attestation_payload.length > 0 && attestation_signature.length > 0; + NSString* channel = has_attested_channel && has_attestation ? attested_channel : @"self-built"; + BOOL official = has_attested_channel && has_attestation; + NSString* commit_short = [NSString stringWithUTF8String:XE_BUILD_COMMIT_SHORT]; + NSString* build_id = xe_make_build_id(@"ios", channel, app_version, build_number, build_stage); + + NSMutableDictionary* mutable_build_info = [NSMutableDictionary + dictionaryWithObjectsAndKeys:build_id, @"buildId", channel, @"channel", @(official), + @"official", app_version, @"appVersion", build_number, + @"buildNumber", commit_short, @"commitShort", nil]; + if (build_stage.length > 0) { + mutable_build_info[@"stage"] = build_stage; + } + if (has_attestation) { + mutable_build_info[@"attestation"] = @{ + @"payload" : attestation_payload, + @"signature" : attestation_signature, + }; + } + build_info = [mutable_build_info copy]; + }); + return build_info; +} + +NSArray* xe_compat_statuses(void) { + NSMutableArray* codes = [NSMutableArray arrayWithCapacity:std::size(kCompatStatuses)]; + for (const auto& entry : kCompatStatuses) { + [codes addObject:entry.code]; + } + return codes; +} + +NSArray* xe_compat_status_labels(void) { + NSMutableArray* labels = [NSMutableArray arrayWithCapacity:std::size(kCompatStatuses)]; + for (const auto& entry : kCompatStatuses) { + [labels addObject:entry.label]; + } + return labels; +} + +NSArray* xe_compat_perfs(void) { + NSMutableArray* codes = [NSMutableArray arrayWithCapacity:std::size(kCompatPerfs)]; + for (const auto& entry : kCompatPerfs) { + [codes addObject:entry.code]; + } + return codes; +} + +// Picker labels keep "OK" (vs. xe_compat_perf_label's "Okay") so the report +// submission buttons show the short form the website also uses. +NSArray* xe_compat_perf_labels(void) { return @[ @"Great", @"OK", @"Poor", @"N/A" ]; } + +CGFloat xe_compat_hero_height_for_width(CGFloat width, UIImage* background_art) { + CGFloat hero_width = MAX(width, 0.0); + if (hero_width <= 0.0) { + return 286.0; + } + // When background art is available, derive height from the image aspect + // ratio so the hero card shows more of the artwork. + if (background_art && background_art.size.width > 0.0 && background_art.size.height > 0.0) { + CGFloat image_aspect = background_art.size.width / background_art.size.height; + CGFloat derived = floor(hero_width / image_aspect); + return MIN(360.0, MAX(240.0, derived)); + } + return MIN(360.0, MAX(286.0, floor(hero_width * 0.74))); +} diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_cells.h b/src/xenia/ui/ios/launcher/ios_compat_report_cells.h new file mode 100644 index 000000000..51a0f8872 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_cells.h @@ -0,0 +1,33 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_COMPAT_REPORT_CELLS_H_ +#define XENIA_UI_IOS_COMPAT_REPORT_CELLS_H_ + +#import + +#include + +@interface XeniaCompatReportCells : NSObject ++ (UITableViewCell*)gameCellWithTitle:(NSString*)title + titleID:(uint32_t)title_id + row:(NSInteger)row; ++ (UITableViewCell*)environmentCellForRow:(NSInteger)row; ++ (UITableViewCell*)optionsCellForSection:(NSInteger)section + selectedStatus:(NSInteger)selected_status + selectedPerf:(NSInteger)selected_perf + target:(id)target + statusAction:(SEL)status_action + perfAction:(SEL)perf_action; ++ (UITableViewCell*)screenshotCellWithImage:(UIImage*)image index:(NSInteger)index; ++ (UITableViewCell*)addScreenshotCell; ++ (UITableViewCell*)submitCellSubmitting:(BOOL)submitting; +@end + +#endif // XENIA_UI_IOS_COMPAT_REPORT_CELLS_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm b/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm new file mode 100644 index 000000000..5ac8e22aa --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm @@ -0,0 +1,224 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_compat_report_cells.h" + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +@implementation XeniaCompatReportCells + ++ (UITableViewCell*)gameCellWithTitle:(NSString*)title + titleID:(uint32_t)title_id + row:(NSInteger)row { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.detailTextLabel.adjustsFontSizeToFitWidth = YES; + cell.detailTextLabel.minimumScaleFactor = 0.8; + if (row == 0) { + cell.textLabel.text = @"Title"; + cell.detailTextLabel.text = title; + } else { + cell.textLabel.text = @"Title ID"; + cell.detailTextLabel.text = XEFormatTitleIDHexUpper(title_id); + } + return cell; +} + ++ (UITableViewCell*)environmentCellForRow:(NSInteger)row { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.detailTextLabel.adjustsFontSizeToFitWidth = YES; + cell.detailTextLabel.minimumScaleFactor = 0.8; + + NSDictionary* build_info = xe_current_compat_report_build_info(); + switch (row) { + case 0: + cell.textLabel.text = @"Device"; + cell.detailTextLabel.text = xe_device_display_name(); + break; + case 1: + cell.textLabel.text = @"OS Version"; + cell.detailTextLabel.text = [UIDevice currentDevice].systemVersion; + break; + case 2: + cell.textLabel.text = @"Architecture"; + cell.detailTextLabel.text = @"arm64"; + break; + case 3: + cell.textLabel.text = @"GPU Backend"; + cell.detailTextLabel.text = @"msl"; + break; + case 4: + cell.textLabel.text = @"Build"; + cell.detailTextLabel.text = xe_user_facing_build_label(build_info); + break; + default: + break; + } + return cell; +} + ++ (UIButton*)optionButtonWithTitle:(NSString*)title + color:(UIColor*)color + selected:(BOOL)selected + enabled:(BOOL)enabled + target:(id)target + action:(SEL)action + tag:(NSInteger)tag { + UIButton* button = [UIButton buttonWithType:UIButtonTypeSystem]; + button.translatesAutoresizingMaskIntoConstraints = NO; + [button setTitle:title forState:UIControlStateNormal]; + [button setTitleColor:color forState:UIControlStateNormal]; + xe_apply_button_title_font(button, UIFontTextStyleCaption1, 13.0, UIFontWeightSemibold); + button.contentEdgeInsets = UIEdgeInsetsMake(6.0, 12.0, 6.0, 12.0); + button.backgroundColor = [color colorWithAlphaComponent:selected ? 0.16 : 0.10]; + button.layer.cornerRadius = XeniaRadiusLg; + button.layer.borderWidth = selected ? 1.0 : 0.0; + button.layer.borderColor = [color colorWithAlphaComponent:0.45].CGColor; + button.enabled = enabled; + button.alpha = enabled ? 1.0 : 0.35; + button.tag = tag; + [button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; + return button; +} + ++ (UITableViewCell*)optionsCellForSection:(NSInteger)section + selectedStatus:(NSInteger)selected_status + selectedPerf:(NSInteger)selected_perf + target:(id)target + statusAction:(SEL)status_action + perfAction:(SEL)perf_action { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.backgroundColor = [UIColor clearColor]; + cell.contentView.backgroundColor = [UIColor clearColor]; + + NSArray* keys = section == 2 ? xe_compat_statuses() : xe_compat_perfs(); + NSArray* labels = section == 2 ? xe_compat_status_labels() : xe_compat_perf_labels(); + + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [XeniaTheme bgSurface]; + card.layer.cornerRadius = XeniaRadiusXl; + card.layer.borderWidth = 0.5; + card.layer.borderColor = [XeniaTheme border].CGColor; + [cell.contentView addSubview:card]; + [NSLayoutConstraint activateConstraints:@[ + [card.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:6.0], + [card.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16.0], + [card.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16.0], + [card.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-6.0], + ]]; + + UIStackView* vertical_stack = [[[UIStackView alloc] init] autorelease]; + vertical_stack.translatesAutoresizingMaskIntoConstraints = NO; + vertical_stack.axis = UILayoutConstraintAxisVertical; + vertical_stack.spacing = 10.0; + [card addSubview:vertical_stack]; + + NSMutableArray*>* rows = [NSMutableArray array]; + if (section == 2) { + [rows addObject:@[ @0, @1, @2 ]]; + [rows addObject:@[ @3, @4 ]]; + } else { + [rows addObject:@[ @0, @1 ]]; + [rows addObject:@[ @2, @3 ]]; + } + + for (NSArray* row_indexes in rows) { + UIStackView* row = [[[UIStackView alloc] init] autorelease]; + row.axis = UILayoutConstraintAxisHorizontal; + row.spacing = 10.0; + row.alignment = UIStackViewAlignmentLeading; + row.distribution = UIStackViewDistributionFillProportionally; + + for (NSNumber* index_number in row_indexes) { + NSInteger option_index = [index_number integerValue]; + NSString* key = keys[option_index]; + NSString* label = labels[option_index]; + UIColor* color = section == 2 ? xe_compat_status_color(key) : xe_compat_perf_color(key); + BOOL selected = + section == 2 ? (option_index == selected_status) : (option_index == selected_perf); + BOOL enabled = YES; + if (section == 3) { + BOOL force_na = (selected_status == 4); + enabled = !force_na || option_index == 3; + } + + UIButton* button = + [self optionButtonWithTitle:label + color:color + selected:selected + enabled:enabled + target:target + action:(section == 2) ? status_action : perf_action + tag:option_index]; + [row addArrangedSubview:button]; + } + + UIView* spacer = [[[UIView alloc] init] autorelease]; + spacer.translatesAutoresizingMaskIntoConstraints = NO; + [spacer.widthAnchor constraintGreaterThanOrEqualToConstant:1.0].active = YES; + [row addArrangedSubview:spacer]; + [vertical_stack addArrangedSubview:row]; + } + + [NSLayoutConstraint activateConstraints:@[ + [vertical_stack.topAnchor constraintEqualToAnchor:card.topAnchor constant:14.0], + [vertical_stack.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [vertical_stack.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [vertical_stack.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-14.0], + ]]; + + return cell; +} + ++ (UITableViewCell*)screenshotCellWithImage:(UIImage*)image index:(NSInteger)index { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.imageView.image = image; + cell.imageView.contentMode = UIViewContentModeScaleAspectFill; + cell.imageView.clipsToBounds = YES; + cell.imageView.layer.cornerRadius = XeniaRadiusXs; + cell.textLabel.text = [NSString stringWithFormat:@"Screenshot %ld", (long)(index + 1)]; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + return cell; +} + ++ (UITableViewCell*)addScreenshotCell { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.textLabel.text = @"Add Screenshot"; + cell.textLabel.textColor = [XeniaTheme accent]; + cell.imageView.image = [UIImage systemImageNamed:@"plus.circle"]; + cell.imageView.tintColor = [XeniaTheme accent]; + return cell; +} + ++ (UITableViewCell*)submitCellSubmitting:(BOOL)submitting { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.backgroundColor = [XeniaTheme accent]; + cell.clipsToBounds = YES; + cell.layer.cornerRadius = XeniaRadiusMd; + cell.textLabel.text = submitting ? @"Submitting..." : @"Submit Report"; + cell.textLabel.textColor = [XeniaTheme accentFg]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + xe_apply_label_font(cell.textLabel, UIFontTextStyleHeadline, 17.0, UIFontWeightSemibold); + return cell; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_submission.h b/src/xenia/ui/ios/launcher/ios_compat_report_submission.h new file mode 100644 index 000000000..37b0cb9f6 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_submission.h @@ -0,0 +1,40 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_COMPAT_REPORT_SUBMISSION_H_ +#define XENIA_UI_IOS_COMPAT_REPORT_SUBMISSION_H_ + +#import +#import + +#include + +typedef void (^XeniaCompatReportSubmissionCompletion)(NSString* issue_url, + NSDictionary* compat_info, + NSDictionary* discussion_snapshot, + NSString* error_title, + NSString* error_message); + +typedef void (^XeniaCompatReportScreenshotLoadCompletion)(NSArray* images); + +@interface XeniaCompatReportSubmission : NSObject ++ (void)submitReportForTitleID:(uint32_t)title_id + title:(NSString*)title + status:(NSString*)status + perf:(NSString*)perf + notes:(NSString*)notes + screenshots:(NSArray*)screenshots + completion:(XeniaCompatReportSubmissionCompletion)completion; ++ (void)loadSanitizedScreenshotsFromPickerResults:(NSArray*)results + availableSlots:(NSUInteger)available_slots + completion: + (XeniaCompatReportScreenshotLoadCompletion)completion; +@end + +#endif // XENIA_UI_IOS_COMPAT_REPORT_SUBMISSION_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_submission.mm b/src/xenia/ui/ios/launcher/ios_compat_report_submission.mm new file mode 100644 index 000000000..f230bcd84 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_submission.mm @@ -0,0 +1,226 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_compat_report_submission.h" + +#include "xenia/base/logging.h" +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +void xe_finish_report_submission(XeniaCompatReportSubmissionCompletion completion, + NSString* issue_url, NSDictionary* compat_info, + NSDictionary* discussion_snapshot, NSString* error_title, + NSString* error_message) { + if (!completion) { + return; + } + completion(issue_url, compat_info, discussion_snapshot, error_title, error_message); +} + +UIImage* xe_sanitized_report_screenshot(UIImage* image) { + if (!image) { + return nil; + } + + CGFloat max_dimension = 1280.0; + CGSize size = image.size; + CGFloat scale = 1.0; + if (size.width > max_dimension || size.height > max_dimension) { + scale = (size.width > size.height) ? (max_dimension / size.width) + : (max_dimension / size.height); + } + CGSize new_size = CGSizeMake(size.width * scale, size.height * scale); + UIGraphicsBeginImageContextWithOptions(new_size, NO, 1.0); + [image drawInRect:CGRectMake(0, 0, new_size.width, new_size.height)]; + UIImage* sanitized_image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return sanitized_image; +} + +} // namespace + +@implementation XeniaCompatReportSubmission + ++ (void)submitReportForTitleID:(uint32_t)title_id + title:(NSString*)title + status:(NSString*)status + perf:(NSString*)perf + notes:(NSString*)notes + screenshots:(NSArray*)screenshots + completion:(XeniaCompatReportSubmissionCompletion)completion { + NSMutableArray* screenshot_data = + [NSMutableArray arrayWithCapacity:screenshots.count]; + NSUInteger screenshot_total_bytes = 0; + for (UIImage* image in screenshots) { + NSData* jpeg = UIImageJPEGRepresentation(image, 0.8); + if (!jpeg) { + continue; + } + screenshot_total_bytes += jpeg.length; + [screenshot_data addObject:[jpeg base64EncodedStringWithOptions:0]]; + } + + NSString* device_machine = xe_device_machine(); + NSString* device_display = xe_device_display_name(); + NSDictionary* build_info = xe_current_compat_report_build_info(); + NSDictionary* payload = @{ + @"titleId" : XEFormatTitleIDHexUpper(title_id), + @"title" : title ?: @"", + @"status" : status ?: @"", + @"perf" : perf ?: @"", + @"platform" : @"ios", + @"device" : device_display ?: @"Unknown", + @"deviceMachine" : device_machine ?: @"Unknown", + @"osVersion" : [UIDevice currentDevice].systemVersion ?: @"", + @"arch" : @"arm64", + @"gpuBackend" : @"msl", + @"notes" : notes ?: @"", + @"tags" : @[], + @"screenshots" : screenshot_data, + @"build" : build_info, + @"buildId" : xe_string_from_object(build_info[@"buildId"]) ?: @"", + @"channel" : xe_string_from_object(build_info[@"channel"]) ?: @"self-built", + @"official" : build_info[@"official"] ?: @NO, + @"appVersion" : xe_string_from_object(build_info[@"appVersion"]) ?: @"", + @"buildNumber" : xe_string_from_object(build_info[@"buildNumber"]) ?: @"", + @"commitShort" : xe_string_from_object(build_info[@"commitShort"]) ?: @"", + }; + + NSError* json_error = nil; + NSData* request_body = [NSJSONSerialization dataWithJSONObject:payload + options:0 + error:&json_error]; + if (!request_body) { + NSString* message = + json_error.localizedDescription ?: @"Unable to serialize the report payload."; + xe_finish_report_submission(completion, nil, nil, nil, @"Submission Failed", message); + return; + } + + XELOGI("iOS compat submit: title_id={:08X} status={} perf={} channel={} build_id={} " + "commit={} screenshots={} screenshot_bytes={} body_bytes={}", + title_id, [status UTF8String], [perf UTF8String], + [xe_string_from_object(build_info[@"channel"]) UTF8String], + [xe_string_from_object(build_info[@"buildId"]) UTF8String], + [xe_string_from_object(build_info[@"commitShort"]) UTF8String], (int)screenshot_data.count, + static_cast(screenshot_total_bytes), + static_cast(request_body.length)); + + NSURL* url = [NSURL URLWithString:@"https://xenios-compat-api.xenios.workers.dev/report"]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"POST"; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request setValue:@"Bearer xenios-compat-report" forHTTPHeaderField:@"Authorization"]; + request.HTTPBody = request_body; + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error) { + xe_finish_report_submission(completion, nil, nil, nil, @"Network Error", + error.localizedDescription); + return; + } + + NSHTTPURLResponse* http_response = (NSHTTPURLResponse*)response; + NSInteger status_code = [http_response isKindOfClass:[NSHTTPURLResponse class]] + ? http_response.statusCode + : 0; + + NSString* response_text = @""; + if (data.length > 0) { + NSString* body_text = + [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] autorelease]; + if (body_text.length > 0) { + response_text = body_text; + } + } + + if (![http_response isKindOfClass:[NSHTTPURLResponse class]] || status_code < 200 || + status_code >= 300) { + NSString* message = + [NSString stringWithFormat:@"Server returned HTTP %ld", (long)status_code]; + if (response_text.length > 0) { + message = [message stringByAppendingFormat:@"\n%@", response_text]; + } + xe_finish_report_submission(completion, nil, nil, nil, @"Submission Failed", + message); + return; + } + + NSString* issue_url = nil; + if (data.length > 0) { + id response_json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([response_json isKindOfClass:[NSDictionary class]] && + [response_json[@"issueUrl"] isKindOfClass:[NSString class]]) { + issue_url = response_json[@"issueUrl"]; + } + } + + NSDictionary* local_report = + xe_build_local_compat_report(device_display, device_machine, + [UIDevice currentDevice].systemVersion, status, perf, + notes, build_info); + NSDictionary* compat_info = + xe_update_cached_compat_entry_for_submission(title_id, title, local_report, + issue_url); + NSDictionary* discussion_snapshot = + xe_cache_discussion_snapshot_for_submission(title_id, compat_info); + + xe_finish_report_submission(completion, issue_url, compat_info, discussion_snapshot, + nil, nil); + }); + }]; + [task resume]; +} + ++ (void)loadSanitizedScreenshotsFromPickerResults:(NSArray*)results + availableSlots:(NSUInteger)available_slots + completion: + (XeniaCompatReportScreenshotLoadCompletion)completion { + if (results.count == 0 || available_slots == 0) { + if (completion) { + completion(@[]); + } + return; + } + + NSUInteger count = MIN(results.count, available_slots); + NSMutableArray* images = [[NSMutableArray alloc] initWithCapacity:count]; + dispatch_group_t group = dispatch_group_create(); + + for (NSUInteger index = 0; index < count; ++index) { + PHPickerResult* result = results[index]; + dispatch_group_enter(group); + [result.itemProvider + loadObjectOfClass:[UIImage class] + completionHandler:^(id object, NSError* __unused error) { + UIImage* sanitized_image = xe_sanitized_report_screenshot((UIImage*)object); + if (sanitized_image) { + @synchronized(images) { + [images addObject:sanitized_image]; + } + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + if (completion) { + completion(images); + } + [images release]; + }); +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.h b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.h new file mode 100644 index 000000000..3622bb821 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.h @@ -0,0 +1,29 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_COMPAT_REPORT_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_COMPAT_REPORT_VIEW_CONTROLLER_H_ + +#import +#import + +#include + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// "Submit a Report" sheet. Lets the user pick a status, perf rating, optional +// notes and an optional screenshot, signs the payload with the build's +// attestation if one is present and uploads it via the compat-submission API +// (falling back to GitHub discussion creation when the worker is offline). +@interface XeniaCompatReportViewController + : XESheetTableViewController +- (instancetype)initWithTitleID:(uint32_t)title_id title:(NSString*)title; +@end + +#endif // XENIA_UI_IOS_COMPAT_REPORT_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm new file mode 100644 index 000000000..b6ed97897 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm @@ -0,0 +1,551 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_compat_report_view_controller.h" + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/launcher/ios_compat_report_cells.h" +#import "xenia/ui/ios/launcher/ios_compat_report_submission.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaCompatReportViewController { + uint32_t title_id_; + NSString* game_title_; + NSInteger selected_status_; + NSInteger selected_perf_; + UITextView* notes_text_view_; + UILabel* notes_placeholder_label_; + UIBarButtonItem* keyboard_done_button_; + NSMutableArray* screenshots_; + BOOL submitting_; +} + +- (instancetype)initWithTitleID:(uint32_t)title_id title:(NSString*)title { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + title_id_ = title_id; + game_title_ = [title copy]; + selected_status_ = -1; + selected_perf_ = -1; + screenshots_ = [[NSMutableArray alloc] init]; + submitting_ = NO; + self.title = @"Submit Report"; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [game_title_ release]; + [notes_text_view_ release]; + [notes_placeholder_label_ release]; + [keyboard_done_button_ release]; + [screenshots_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [XeniaTheme bgPrimary]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 64.0; + self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; + keyboard_done_button_ = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(dismissKeyboard)]; + keyboard_done_button_.tintColor = [XeniaTheme accent]; + if (@available(iOS 15.0, *)) { + self.tableView.sectionHeaderTopPadding = 0; + } + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(keyboardWillChangeFrame:) + name:UIKeyboardWillChangeFrameNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(keyboardWillHide:) + name:UIKeyboardWillHideNotification + object:nil]; +} + +- (void)scrollNotesEditorIntoViewAnimated:(BOOL)animated { + NSIndexPath* notes_path = [NSIndexPath indexPathForRow:0 inSection:4]; + if ([self.tableView numberOfSections] <= notes_path.section || + [self.tableView numberOfRowsInSection:notes_path.section] <= notes_path.row) { + return; + } + [self.tableView scrollToRowAtIndexPath:notes_path + atScrollPosition:UITableViewScrollPositionTop + animated:animated]; + if (!notes_text_view_) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + CGRect target = [self->notes_text_view_ convertRect:self->notes_text_view_.bounds + toView:self.tableView]; + target = CGRectInset(target, 0.0, -12.0); + [self.tableView scrollRectToVisible:target animated:animated]; + }); +} + +- (void)keyboardWillChangeFrame:(NSNotification*)notification { + NSDictionary* user_info = notification.userInfo; + CGRect keyboard_end = [user_info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + CGRect keyboard_in_view = [self.view convertRect:keyboard_end fromView:nil]; + CGFloat overlap = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(keyboard_in_view)); + CGFloat safe_bottom = self.view.safeAreaInsets.bottom; + CGFloat bottom_inset = MAX(0.0, overlap - safe_bottom); + + NSTimeInterval duration = [user_info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationOptions options = + (UIViewAnimationOptions)([user_info[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); + + [UIView animateWithDuration:duration + delay:0.0 + options:options + animations:^{ + UIEdgeInsets content_inset = self.tableView.contentInset; + content_inset.bottom = bottom_inset + 16.0; + self.tableView.contentInset = content_inset; + self.tableView.scrollIndicatorInsets = content_inset; + } + completion:nil]; + + if ([notes_text_view_ isFirstResponder]) { + [self scrollNotesEditorIntoViewAnimated:YES]; + } +} + +- (void)keyboardWillHide:(NSNotification*)notification { + NSDictionary* user_info = notification.userInfo; + NSTimeInterval duration = [user_info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationOptions options = + (UIViewAnimationOptions)([user_info[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); + [UIView animateWithDuration:duration + delay:0.0 + options:options + animations:^{ + UIEdgeInsets content_inset = self.tableView.contentInset; + content_inset.bottom = 0.0; + self.tableView.contentInset = content_inset; + self.tableView.scrollIndicatorInsets = content_inset; + } + completion:nil]; +} + +- (void)textViewDidBeginEditing:(UITextView*)textView { + if (textView != notes_text_view_) { + return; + } + self.navigationItem.rightBarButtonItem = keyboard_done_button_; + [self scrollNotesEditorIntoViewAnimated:YES]; +} + +- (void)textViewDidChange:(UITextView*)textView { + if (textView == notes_text_view_) { + notes_placeholder_label_.hidden = (textView.text.length > 0); + } +} + +- (void)textViewDidEndEditing:(UITextView*)textView { + if (textView == notes_text_view_) { + self.navigationItem.rightBarButtonItem = nil; + } +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if ([self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + // Option-button + card borders are CGColor (frozen at assignment). Cells + // are rebuilt on reload, which re-resolves XeniaTheme via the new trait + // collection. + [self.tableView reloadData]; + } +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + if (!notes_text_view_ || !notes_placeholder_label_) { + return; + } + UIEdgeInsets insets = notes_text_view_.textContainerInset; + CGFloat line_padding = notes_text_view_.textContainer.lineFragmentPadding; + CGFloat available_width = + CGRectGetWidth(notes_text_view_.bounds) - insets.left - insets.right - (line_padding * 2.0); + if (available_width > 0.0) { + notes_placeholder_label_.preferredMaxLayoutWidth = floor(available_width); + } +} + +- (void)reportStatusButtonTapped:(UIButton*)sender { + selected_status_ = sender.tag; + if (selected_status_ == 4) { + selected_perf_ = 3; + } + [self.tableView reloadSections:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(2, 2)] + withRowAnimation:UITableViewRowAnimationNone]; +} + +- (void)reportPerfButtonTapped:(UIButton*)sender { + if (selected_status_ == 4 && sender.tag != 3) { + return; + } + selected_perf_ = sender.tag; + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:3] + withRowAnimation:UITableViewRowAnimationNone]; +} + +- (void)dismissKeyboard { + [notes_text_view_ resignFirstResponder]; +} + +- (void)showAlertWithTitle:(NSString*)title message:(NSString*)message { + XEPresentOKAlert(self, title, message); +} + +- (void)finishSuccessfulSubmissionWithIssueURL:(NSString*)issue_url + compatInfo:(NSDictionary*)compat_info + discussionSnapshot:(NSDictionary*)discussion_snapshot { + NSMutableDictionary* compat_user_info = [NSMutableDictionary dictionaryWithObject:@(title_id_) + forKey:@"titleId"]; + if (compat_info) { + compat_user_info[@"compatInfo"] = compat_info; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kXeniaCompatDataDidUpdateNotification + object:nil + userInfo:compat_user_info]; + + NSMutableDictionary* discussion_user_info = [NSMutableDictionary dictionaryWithObject:@(title_id_) + forKey:@"titleId"]; + if (discussion_snapshot) { + discussion_user_info[@"discussion"] = discussion_snapshot; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kXeniaDiscussionDidUpdateNotification + object:nil + userInfo:discussion_user_info]; + + NSString* message = @"Your compatibility report has been submitted."; + if ([issue_url isKindOfClass:[NSString class]] && issue_url.length > 0) { + message = [message stringByAppendingFormat:@"\n\nGitHub issue: %@", issue_url]; + } + + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:@"Report Submitted" + message:message + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction + actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + if (self.navigationController) { + [self.navigationController popViewControllerAnimated:YES]; + } else { + [self dismissViewControllerAnimated:YES completion:nil]; + } + }]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (NSString*)trimmedNotes { + return [notes_text_view_.text + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +- (void)submitReport { + if (submitting_) { + return; + } + if (selected_status_ < 0) { + [self showAlertWithTitle:@"Missing Status" message:@"Please select a compatibility status."]; + return; + } + if (selected_perf_ < 0) { + [self showAlertWithTitle:@"Missing Performance" message:@"Please select a performance tier."]; + return; + } + + NSString* notes = [self trimmedNotes]; + if (notes.length == 0) { + [self showAlertWithTitle:@"Missing Notes" + message:@"Please add a short note about your experience."]; + return; + } + + submitting_ = YES; + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:6] + withRowAnimation:UITableViewRowAnimationNone]; + + NSString* status = xe_compat_statuses()[selected_status_]; + NSString* perf = xe_compat_perfs()[selected_perf_]; + [XeniaCompatReportSubmission + submitReportForTitleID:title_id_ + title:game_title_ + status:status + perf:perf + notes:notes + screenshots:screenshots_ + completion:^(NSString* issue_url, NSDictionary* compat_info, + NSDictionary* discussion_snapshot, NSString* error_title, + NSString* error_message) { + self->submitting_ = NO; + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:6] + withRowAnimation:UITableViewRowAnimationNone]; + + if (error_title.length > 0) { + [self showAlertWithTitle:error_title message:error_message ?: @""]; + return; + } + + [self finishSuccessfulSubmissionWithIssueURL:issue_url + compatInfo:compat_info + discussionSnapshot:discussion_snapshot]; + }]; +} + +- (void)addScreenshotTapped { + if (screenshots_.count >= 5) { + [self showAlertWithTitle:@"Limit Reached" message:@"You can attach up to 5 screenshots."]; + return; + } + + PHPickerConfiguration* configuration = [[PHPickerConfiguration alloc] init]; + configuration.selectionLimit = static_cast(5 - screenshots_.count); + configuration.filter = [PHPickerFilter imagesFilter]; + + PHPickerViewController* picker = + [[PHPickerViewController alloc] initWithConfiguration:configuration]; + picker.delegate = self; + [self presentViewController:picker animated:YES completion:nil]; + [picker release]; + [configuration release]; +} + +#pragma mark - PHPickerViewControllerDelegate + +- (void)picker:(PHPickerViewController*)picker didFinishPicking:(NSArray*)results { + [picker dismissViewControllerAnimated:YES completion:nil]; + + NSUInteger available_slots = screenshots_.count >= 5 ? 0 : 5 - screenshots_.count; + [XeniaCompatReportSubmission + loadSanitizedScreenshotsFromPickerResults:results + availableSlots:available_slots + completion:^(NSArray* images) { + BOOL added = NO; + for (UIImage* image in images) { + if (self->screenshots_.count >= 5) { + break; + } + [self->screenshots_ addObject:image]; + added = YES; + } + if (added) { + [self.tableView + reloadSections:[NSIndexSet indexSetWithIndex:5] + withRowAnimation:UITableViewRowAnimationAutomatic]; + } + }]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 7; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView numberOfRowsInSection:(NSInteger)section { + switch (section) { + case 0: + return 2; + case 1: + return 5; + case 2: + return 1; + case 3: + return 1; + case 4: + return 1; + case 5: + return static_cast(screenshots_.count) + 1; + case 6: + return 1; + default: + return 0; + } +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForHeaderInSection:(NSInteger)section { + switch (section) { + case 0: + return @"Game"; + case 1: + return @"Environment"; + case 2: + return @"Compatibility Status"; + case 3: + return @"Performance"; + case 4: + return @"Notes"; + case 5: + return @"Screenshots"; + default: + return nil; + } +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForFooterInSection:(NSInteger)section { + if (section != 1) { + return nil; + } + NSDictionary* build_info = xe_current_compat_report_build_info(); + NSString* build_label = xe_user_facing_build_label(build_info); + return build_label.length > 0 + ? [NSString stringWithFormat:@"Reports are tagged as %@.", build_label] + : nil; +} + +- (CGFloat)tableView:(UITableView* __unused)tableView + heightForRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.section == 4) { + return 128.0; + } + if (indexPath.section == 6) { + return 52.0; + } + return UITableViewAutomaticDimension; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.section == 0 || indexPath.section == 1) { + if (indexPath.section == 0) { + return [XeniaCompatReportCells gameCellWithTitle:game_title_ + titleID:title_id_ + row:indexPath.row]; + } + return [XeniaCompatReportCells environmentCellForRow:indexPath.row]; + } + + if (indexPath.section == 2 || indexPath.section == 3) { + return [XeniaCompatReportCells optionsCellForSection:indexPath.section + selectedStatus:selected_status_ + selectedPerf:selected_perf_ + target:self + statusAction:@selector(reportStatusButtonTapped:) + perfAction:@selector(reportPerfButtonTapped:)]; + } + + if (indexPath.section == 4) { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + if (!notes_text_view_) { + notes_text_view_ = [[UITextView alloc] init]; + notes_text_view_.delegate = self; + notes_text_view_.backgroundColor = [UIColor clearColor]; + notes_text_view_.textColor = [XeniaTheme textPrimary]; + notes_text_view_.textContainerInset = UIEdgeInsetsMake(8, 4, 8, 4); + xe_apply_text_view_font(notes_text_view_, UIFontTextStyleBody, 15.0, UIFontWeightRegular, NO); + + notes_placeholder_label_ = [[UILabel alloc] init]; + notes_placeholder_label_.translatesAutoresizingMaskIntoConstraints = NO; + notes_placeholder_label_.text = @"Describe your experience (e.g. crashes, graphical " + @"glitches, audio issues, performance drops)..."; + notes_placeholder_label_.textColor = [XeniaTheme textMuted]; + notes_placeholder_label_.numberOfLines = 0; + notes_placeholder_label_.lineBreakMode = NSLineBreakByWordWrapping; + notes_placeholder_label_.userInteractionEnabled = NO; + xe_apply_label_font(notes_placeholder_label_, UIFontTextStyleBody, 15.0, UIFontWeightRegular); + } + + if (notes_text_view_.superview != cell.contentView) { + notes_text_view_.translatesAutoresizingMaskIntoConstraints = NO; + [cell.contentView addSubview:notes_text_view_]; + [NSLayoutConstraint activateConstraints:@[ + [notes_text_view_.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:8], + [notes_text_view_.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor + constant:-8], + [notes_text_view_.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor + constant:8], + [notes_text_view_.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor + constant:-8], + ]]; + } + if (notes_placeholder_label_.superview != cell.contentView) { + UIEdgeInsets insets = notes_text_view_.textContainerInset; + CGFloat line_padding = notes_text_view_.textContainer.lineFragmentPadding; + [cell.contentView addSubview:notes_placeholder_label_]; + [NSLayoutConstraint activateConstraints:@[ + [notes_placeholder_label_.topAnchor constraintEqualToAnchor:notes_text_view_.topAnchor + constant:insets.top], + [notes_placeholder_label_.leadingAnchor + constraintEqualToAnchor:notes_text_view_.leadingAnchor + constant:insets.left + line_padding], + [notes_placeholder_label_.trailingAnchor + constraintEqualToAnchor:notes_text_view_.trailingAnchor + constant:-(insets.right + line_padding)], + ]]; + } + notes_placeholder_label_.hidden = (notes_text_view_.text.length > 0); + return cell; + } + + if (indexPath.section == 5) { + if (indexPath.row < static_cast(screenshots_.count)) { + return [XeniaCompatReportCells screenshotCellWithImage:screenshots_[indexPath.row] + index:indexPath.row]; + } + + return [XeniaCompatReportCells addScreenshotCell]; + } + + return [XeniaCompatReportCells submitCellSubmitting:submitting_]; +} + +#pragma mark - UITableViewDelegate + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (indexPath.section == 4) { + [notes_text_view_ becomeFirstResponder]; + return; + } + + if (indexPath.section == 5) { + if (indexPath.row >= static_cast(screenshots_.count)) { + [self addScreenshotTapped]; + } + return; + } + + if (indexPath.section == 6) { + [self submitReport]; + } +} + +- (BOOL)tableView:(UITableView* __unused)tableView canEditRowAtIndexPath:(NSIndexPath*)indexPath { + return (indexPath.section == 5 && indexPath.row < static_cast(screenshots_.count)); +} + +- (void)tableView:(UITableView*)tableView + commitEditingStyle:(UITableViewCellEditingStyle)editingStyle + forRowAtIndexPath:(NSIndexPath*)indexPath { + if (editingStyle != UITableViewCellEditingStyleDelete) { + return; + } + if (indexPath.section != 5 || indexPath.row >= static_cast(screenshots_.count)) { + return; + } + [screenshots_ removeObjectAtIndex:indexPath.row]; + [tableView reloadSections:[NSIndexSet indexSetWithIndex:5] + withRowAnimation:UITableViewRowAnimationAutomatic]; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_compat_summary.h b/src/xenia/ui/ios/launcher/ios_compat_summary.h new file mode 100644 index 000000000..1b3ba04f0 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_summary.h @@ -0,0 +1,15 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_SUMMARY_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_SUMMARY_H_ + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_COMPAT_SUMMARY_H_ diff --git a/src/xenia/ui/ios/launcher/ios_compat_summary.mm b/src/xenia/ui/ios/launcher/ios_compat_summary.mm new file mode 100644 index 000000000..ac8967f88 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_compat_summary.mm @@ -0,0 +1,390 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + + +#import "xenia/ui/ios/launcher/ios_compat_summary.h" + +#import "xenia/ui/ios/launcher/ios_compat_formatting.h" + +static BOOL xe_compat_entry_has_summary_fields(NSDictionary* entry) { + if (!entry) { + return NO; + } + return xe_string_from_object(entry[@"status"]).length > 0 || + xe_string_from_object(entry[@"perf"]).length > 0 || + xe_string_from_object(entry[@"notes"]).length > 0; +} + +static NSDictionary* xe_compat_summary_named(NSDictionary* compat_info, NSString* key) { + NSDictionary* summaries = xe_dictionary_from_object(compat_info[@"summaries"]); + NSDictionary* summary = summaries ? xe_dictionary_from_object(summaries[key]) : nil; + return xe_compat_entry_has_summary_fields(summary) ? summary : nil; +} + +NSDictionary* xe_release_summary_from_compat_info(NSDictionary* compat_info) { + return xe_compat_summary_named(compat_info, @"release"); +} + + +static BOOL xe_compat_date_is_newer(NSString* candidate, NSString* baseline) { + if (candidate.length == 0) { + return NO; + } + if (baseline.length == 0) { + return YES; + } + return [candidate compare:baseline options:NSCaseInsensitiveSearch] == NSOrderedDescending; +} + +static NSDictionary* xe_compat_newest_report(NSArray* reports) { + NSDictionary* newest = nil; + NSString* newest_date = @""; + NSInteger newest_rank = -1; + for (NSDictionary* report in reports) { + NSString* date = xe_string_from_object(report[@"date"]) ?: @""; + NSString* status = xe_string_from_object(report[@"status"]) ?: @""; + NSInteger rank = xe_compat_status_rank(status); + if (!newest || xe_compat_date_is_newer(date, newest_date) || + ([date isEqualToString:newest_date] && rank > newest_rank)) { + newest = report; + newest_date = date; + newest_rank = rank; + } + } + return newest; +} + +// Strict "newest by date" with no rank tiebreak — mirrors the worker's +// latestReportForReports (data/compatibility.json's summaries.* are produced +// by this rule). +static NSDictionary* xe_compat_latest_report(NSArray* reports) { + NSDictionary* latest = nil; + NSString* latest_date = @""; + for (NSDictionary* report in reports) { + NSString* date = xe_string_from_object(report[@"date"]) ?: @""; + if (!latest || xe_compat_date_is_newer(date, latest_date)) { + latest = report; + latest_date = date; + } + } + return latest; +} + +static NSDictionary* xe_compat_best_report(NSArray* reports) { + NSDictionary* best = nil; + NSInteger best_rank = -1; + NSString* best_date = @""; + for (NSDictionary* report in reports) { + NSString* status = xe_string_from_object(report[@"status"]) ?: @""; + NSInteger rank = xe_compat_status_rank(status); + NSString* date = xe_string_from_object(report[@"date"]) ?: @""; + if (!best || rank > best_rank || + (rank == best_rank && xe_compat_date_is_newer(date, best_date))) { + best = report; + best_rank = rank; + best_date = date; + } + } + return best; +} + +// Mirrors the compat worker's reportMatchesSummaryChannel for "release": +// strict buildId match against the currently-published release build, with a +// legacy fallback that accepts reports missing build metadata or carrying +// channel="release". See worker/src/index.ts (xenios-jp/xenios.jp). +static BOOL xe_compat_release_filter_matches(NSDictionary* report, + NSDictionary* current_release_build) { + NSDictionary* build = xe_dictionary_from_object(report[@"build"]); + NSString* current_build_id = xe_string_from_object(current_release_build[@"buildId"]); + if (current_build_id.length > 0) { + NSString* report_channel = xe_string_from_object(build[@"channel"]); + NSString* report_build_id = xe_string_from_object(build[@"buildId"]); + return [report_channel isEqualToString:@"release"] && + [report_build_id isEqualToString:current_build_id]; + } + if (!build) { + return YES; + } + NSString* channel = xe_string_from_object(build[@"channel"]); + if (channel.length == 0) { + return YES; + } + return [channel isEqualToString:@"release"]; +} + +// Mirrors the compat worker's buildSummaryForChannel — produces summaries in +// the exact shape that data/compatibility.json's summaries.* fields use +// (channel, status, perf, notes, updatedAt, reportCount, latestReport, +// bestReport). Pass `current_release_build` from the release-builds manifest +// when summarising the release channel; pass nil for "all". +static NSDictionary* xe_compat_summarize_reports_for_channel(NSArray* reports, + NSDictionary* current_release_build, + NSString* channel) { + NSMutableArray* matching = [NSMutableArray arrayWithCapacity:reports.count]; + if ([channel isEqualToString:@"all"]) { + for (id raw in reports) { + NSDictionary* report = xe_dictionary_from_object(raw); + if (report) { + [matching addObject:report]; + } + } + } else { + for (id raw in reports) { + NSDictionary* report = xe_dictionary_from_object(raw); + if (report && xe_compat_release_filter_matches(report, current_release_build)) { + [matching addObject:report]; + } + } + } + + NSDictionary* best_report = xe_compat_best_report(matching); + NSDictionary* latest_report = xe_compat_latest_report(matching); + + NSMutableDictionary* summary = [NSMutableDictionary dictionary]; + summary[@"channel"] = channel ?: @"all"; + summary[@"status"] = + best_report ? (xe_string_from_object(best_report[@"status"]) ?: @"untested") : @"untested"; + NSString* best_perf = xe_string_from_object(best_report[@"perf"]); + summary[@"perf"] = best_perf.length > 0 ? best_perf : (id)[NSNull null]; + summary[@"notes"] = xe_string_from_object(latest_report[@"notes"]) ?: @""; + NSString* latest_date = xe_string_from_object(latest_report[@"date"]); + summary[@"updatedAt"] = latest_date.length > 0 ? latest_date : (id)[NSNull null]; + summary[@"date"] = summary[@"updatedAt"]; + summary[@"reportCount"] = @(matching.count); + summary[@"latestReport"] = latest_report ?: (id)[NSNull null]; + summary[@"bestReport"] = best_report ?: (id)[NSNull null]; + + if (best_report) { + for (NSString* key in @[ @"device", @"platform", @"osVersion", @"arch", @"gpuBackend" ]) { + NSString* value = xe_string_from_object(best_report[key]); + if (value.length > 0) { + summary[key] = value; + } + } + NSDictionary* build_info = xe_dictionary_from_object(best_report[@"build"]); + if (build_info) { + summary[@"build"] = build_info; + } + } + return summary; +} + +// Worker-built summaries leave device / platform / osVersion / arch / +// gpuBackend / build inside the nested bestReport (and latestReport) so the +// summary stays compact. The iOS hero card reads those at the top level — +// flatten them when handing the dict to UI code so older locally-derived +// summaries and worker-built summaries look the same to callers. +static NSDictionary* xe_compat_flatten_summary_detail_fields(NSDictionary* summary) { + if (!summary) { + return nil; + } + NSDictionary* best = xe_dictionary_from_object(summary[@"bestReport"]); + NSDictionary* latest = xe_dictionary_from_object(summary[@"latestReport"]); + NSDictionary* detail = best ?: latest; + if (!detail) { + return summary; + } + NSMutableDictionary* flattened = [[summary mutableCopy] autorelease]; + for (NSString* key in + @[ @"device", @"deviceMachine", @"platform", @"osVersion", @"arch", @"gpuBackend" ]) { + if (xe_string_from_object(flattened[key]).length > 0) { + continue; + } + NSString* value = xe_string_from_object(detail[key]); + if (value.length > 0) { + flattened[key] = value; + } + } + if (!xe_dictionary_from_object(flattened[@"build"])) { + NSDictionary* build = xe_dictionary_from_object(detail[@"build"]); + if (build) { + flattened[@"build"] = build; + } + } + return flattened; +} + +// Status presence check: "untested" passes xe_compat_entry_has_summary_fields +// (it's a non-empty string), but it carries no useful information. The +// preferred-summary picker uses this to skip past untested release summaries +// when a populated all-channel summary is available. +static BOOL xe_compat_summary_has_status_data(NSDictionary* summary) { + if (!summary) { + return NO; + } + NSString* status = xe_string_from_object(summary[@"status"]); + return status.length > 0 && ![status isEqualToString:@"untested"]; +} + +static BOOL xe_compat_summary_is_untested(NSDictionary* summary) { + return [xe_string_from_object(summary[@"status"]) isEqualToString:@"untested"]; +} + +static BOOL xe_compat_platform_is_ios(NSString* platform) { + return platform.length > 0 && [platform caseInsensitiveCompare:@"ios"] == NSOrderedSame; +} + +static BOOL xe_compat_report_is_ios(NSDictionary* report) { + return xe_compat_platform_is_ios(xe_string_from_object(report[@"platform"])); +} + +static BOOL xe_compat_summary_is_ios_report_backed(NSDictionary* summary) { + if (!summary) { + return NO; + } + + BOOL has_nested_report = NO; + NSDictionary* best = xe_dictionary_from_object(summary[@"bestReport"]); + if (best) { + if (!xe_compat_report_is_ios(best)) { + return NO; + } + has_nested_report = YES; + } + NSDictionary* latest = xe_dictionary_from_object(summary[@"latestReport"]); + if (latest) { + if (!xe_compat_report_is_ios(latest)) { + return NO; + } + has_nested_report = YES; + } + if (has_nested_report) { + return YES; + } + + return xe_compat_platform_is_ios(xe_string_from_object(summary[@"platform"])); +} + +static NSArray* xe_compat_ios_reports_from_compat_info(NSDictionary* compat_info) { + NSArray* raw_reports = compat_info[@"reports"]; + if (![raw_reports isKindOfClass:[NSArray class]]) { + return nil; + } + + NSMutableArray* ios_reports = [NSMutableArray arrayWithCapacity:raw_reports.count]; + for (id raw in raw_reports) { + NSDictionary* report = xe_dictionary_from_object(raw); + if (report && xe_compat_report_is_ios(report)) { + [ios_reports addObject:report]; + } + } + return ios_reports; +} + +static NSDictionary* xe_compat_summary_from_ios_reports_for_channel(NSDictionary* compat_info, + NSString* channel) { + NSArray* ios_reports = xe_compat_ios_reports_from_compat_info(compat_info); + if (ios_reports.count == 0) { + return nil; + } + + NSDictionary* derived = xe_compat_summarize_reports_for_channel(ios_reports, nil, channel); + return xe_compat_entry_has_summary_fields(derived) ? derived : nil; +} + +static NSDictionary* xe_compat_untested_summary(NSString* channel) { + NSMutableDictionary* summary = [NSMutableDictionary dictionary]; + summary[@"channel"] = channel ?: @"release"; + summary[@"status"] = @"untested"; + summary[@"perf"] = (id)[NSNull null]; + summary[@"notes"] = @""; + summary[@"reportCount"] = @0; + summary[@"latestReport"] = (id)[NSNull null]; + summary[@"bestReport"] = (id)[NSNull null]; + return summary; +} + +NSDictionary* xe_ios_public_release_summary_from_compat_info(NSDictionary* compat_info) { + // Keep iOS launcher compatibility scoped to iOS reports. The website feed's + // prebuilt summaries may be global across Apple platforms, so only trust + // them here when the report payload is iOS-backed or explicitly untested. + // Otherwise derive the release summary from raw iOS reports using the same + // worker algorithm. + NSDictionary* summaries = xe_dictionary_from_object(compat_info[@"summaries"]); + NSDictionary* prebuilt_release = xe_dictionary_from_object(summaries[@"release"]); + if (xe_compat_entry_has_summary_fields(prebuilt_release) && + (xe_compat_summary_is_untested(prebuilt_release) || + xe_compat_summary_is_ios_report_backed(prebuilt_release))) { + return xe_compat_flatten_summary_detail_fields(prebuilt_release); + } + + NSDictionary* derived = xe_compat_summary_from_ios_reports_for_channel(compat_info, @"release"); + if (derived) { + return derived; + } + + return xe_compat_entry_has_summary_fields(prebuilt_release) + ? xe_compat_untested_summary(@"release") + : nil; +} + +NSDictionary* xe_preferred_summary_from_compat_info(NSDictionary* compat_info) { + // Match the release verdict when it has actual iOS data, but fall back to + // iOS all-channel reports when release is untested. Never use populated + // macOS/global summaries for iOS badges or details. + NSDictionary* summaries = xe_dictionary_from_object(compat_info[@"summaries"]); + NSDictionary* release_summary = xe_dictionary_from_object(summaries[@"release"]); + NSDictionary* all_summary = xe_dictionary_from_object(summaries[@"all"]); + BOOL has_prebuilt_summaries = summaries.count > 0; + if (xe_compat_summary_has_status_data(release_summary) && + xe_compat_summary_is_ios_report_backed(release_summary)) { + return xe_compat_flatten_summary_detail_fields(release_summary); + } + + NSDictionary* ios_release_summary = + xe_compat_summary_from_ios_reports_for_channel(compat_info, @"release"); + if (xe_compat_summary_has_status_data(ios_release_summary)) { + return ios_release_summary; + } + + NSDictionary* ios_all_summary = + xe_compat_summary_from_ios_reports_for_channel(compat_info, @"all"); + if (xe_compat_summary_has_status_data(ios_all_summary)) { + return ios_all_summary; + } + + if (xe_compat_summary_has_status_data(all_summary) && + xe_compat_summary_is_ios_report_backed(all_summary)) { + return xe_compat_flatten_summary_detail_fields(all_summary); + } + + // No populated iOS summary anywhere. Surface an untested release summary + // rather than leaking a populated macOS/global verdict into the iOS UI. + if (xe_compat_entry_has_summary_fields(release_summary) && + (xe_compat_summary_is_untested(release_summary) || + xe_compat_summary_is_ios_report_backed(release_summary))) { + return xe_compat_flatten_summary_detail_fields(release_summary); + } + if (ios_release_summary) { + return ios_release_summary; + } + if (xe_compat_entry_has_summary_fields(all_summary) && + (xe_compat_summary_is_untested(all_summary) || + xe_compat_summary_is_ios_report_backed(all_summary))) { + return xe_compat_flatten_summary_detail_fields(all_summary); + } + + if (has_prebuilt_summaries) { + return xe_compat_untested_summary(@"release"); + } + + // Older feeds carry a top-level status/perf/notes block with no nested + // summaries dictionary; treat the entry itself as the summary in that case. + NSString* legacy_platform = xe_string_from_object(compat_info[@"platform"]); + if (xe_compat_entry_has_summary_fields(compat_info) && + (legacy_platform.length == 0 || xe_compat_platform_is_ios(legacy_platform))) { + return compat_info; + } + + NSDictionary* derived = xe_ios_public_release_summary_from_compat_info(compat_info); + if (derived) { + return derived; + } + return xe_compat_summary_named(compat_info, @"preview"); +} diff --git a/src/xenia/ui/ios/launcher/ios_content_management.h b/src/xenia/ui/ios/launcher/ios_content_management.h new file mode 100644 index 000000000..be835c871 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_content_management.h @@ -0,0 +1,77 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONTENT_MANAGEMENT_H_ +#define XENIA_UI_IOS_CONTENT_MANAGEMENT_H_ + +#import + +#include +#include +#include +#include + +#include "xenia/xbox.h" + +// Title-update / DLC discovery and installation utilities. +// +// On disk content lives under +// {documents}/content/0000000000000000/{title_id_hex_upper}/{type_id}/{pkg}/ +// where type_id is "000B0000" for title updates and "00000002" for DLC. This +// module enumerates installed content packages, copies new ones into place +// (preserving headers + .data sidecar dirs) and reads top-level metadata. + +enum class IOSInstalledContentKind { + kTitleUpdate, + kDlc, +}; + +struct IOSInstalledContentEntry { + IOSInstalledContentKind kind = IOSInstalledContentKind::kTitleUpdate; + std::string name; + std::filesystem::path path; +}; + +struct IOSSelectedContentPackage { + uint32_t title_id = 0; + xe::XContentType content_type = xe::XContentType::kSavedGame; + std::filesystem::path path; +}; + +// True when {path}.data exists and is a directory. Many .gpd / xex container +// packages keep their data in a sibling directory; this helper centralises +// the check used by both the content installer and the importer. +bool HasContentSidecarDataDirectory(const std::filesystem::path& path); + +// Disk locations. +std::filesystem::path xe_title_content_root(uint32_t title_id); +std::filesystem::path xe_title_update_content_root(uint32_t title_id); +std::filesystem::path xe_dlc_content_root(uint32_t title_id); + +// User-visible labels. +NSString* xe_installed_content_kind_label(IOSInstalledContentKind kind); + +// Package metadata + I/O. +std::string xe_content_package_directory_name(const std::filesystem::path& package_path); +bool xe_read_selected_content_package(const std::filesystem::path& path, + IOSSelectedContentPackage* package_out, + NSString** error_message_out); +bool xe_copy_directory_recursive(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::string* error_message_out); +bool xe_copy_content_package_into_root(const IOSSelectedContentPackage& package_info, + const std::filesystem::path& destination_root, + std::string* error_message_out); + +// Enumerators. +void xe_collect_installed_content(const std::filesystem::path& root, IOSInstalledContentKind kind, + std::vector* content_out); +std::vector xe_list_installed_content(uint32_t title_id); + +#endif // XENIA_UI_IOS_CONTENT_MANAGEMENT_H_ diff --git a/src/xenia/ui/ios/launcher/ios_content_management.mm b/src/xenia/ui/ios/launcher/ios_content_management.mm new file mode 100644 index 000000000..dcdb97006 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_content_management.mm @@ -0,0 +1,193 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_content_management.h" + +#include +#include +#include + +#import "xenia/ui/ios/shared/ios_system_utils.h" +#include "xenia/vfs/stfs_metadata.h" + +bool HasContentSidecarDataDirectory(const std::filesystem::path& path) { + std::filesystem::path sidecar_path = path; + sidecar_path += ".data"; + std::error_code ec; + return std::filesystem::is_directory(sidecar_path, ec) && !ec; +} + +std::filesystem::path xe_title_content_root(uint32_t title_id) { + char title_id_buffer[9] = {}; + std::snprintf(title_id_buffer, sizeof(title_id_buffer), "%08X", title_id); + return xe_get_ios_documents_path() / "content" / "0000000000000000" / title_id_buffer; +} + +std::filesystem::path xe_title_update_content_root(uint32_t title_id) { + return xe_title_content_root(title_id) / "000B0000"; +} + +std::filesystem::path xe_dlc_content_root(uint32_t title_id) { + return xe_title_content_root(title_id) / "00000002"; +} + +NSString* xe_installed_content_kind_label(IOSInstalledContentKind kind) { + switch (kind) { + case IOSInstalledContentKind::kTitleUpdate: + return @"Title Update"; + case IOSInstalledContentKind::kDlc: + return @"DLC"; + } + return @"Content"; +} + +std::string xe_content_package_directory_name(const std::filesystem::path& package_path) { + std::string name = package_path.stem().string(); + if (name.empty()) { + name = package_path.filename().string(); + } + return name; +} + +bool xe_read_selected_content_package(const std::filesystem::path& path, + IOSSelectedContentPackage* package_out, + NSString** error_message_out) { + if (error_message_out) { + *error_message_out = nil; + } + + auto metadata = xe::vfs::ExtractStfsMetadata(path); + if (!metadata.has_value()) { + if (error_message_out) { + *error_message_out = @"Could not read the content package header."; + } + return false; + } + + if (metadata->data_file_count > 0 && !HasContentSidecarDataDirectory(path)) { + if (error_message_out) { + *error_message_out = @"This content package is missing its required .data sidecar folder."; + } + return false; + } + + if (package_out) { + package_out->title_id = metadata->title_id; + package_out->content_type = static_cast(metadata->content_type); + package_out->path = path; + } + return true; +} + +bool xe_copy_directory_recursive(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::string* error_message_out) { + std::error_code ec; + std::filesystem::remove_all(destination, ec); + ec.clear(); + std::filesystem::copy( + source, destination, + std::filesystem::copy_options::recursive | std::filesystem::copy_options::overwrite_existing, + ec); + if (ec) { + if (error_message_out) { + *error_message_out = ec.message(); + } + return false; + } + return true; +} + +bool xe_copy_content_package_into_root(const IOSSelectedContentPackage& package_info, + const std::filesystem::path& destination_root, + std::string* error_message_out) { + if (error_message_out) { + *error_message_out = ""; + } + + const std::filesystem::path package_directory = + destination_root / xe_content_package_directory_name(package_info.path); + std::error_code ec; + std::filesystem::create_directories(package_directory, ec); + if (ec) { + if (error_message_out) { + *error_message_out = "Failed creating content folder: " + ec.message(); + } + return false; + } + + const std::filesystem::path destination_file = package_directory / package_info.path.filename(); + std::filesystem::copy_file(package_info.path, destination_file, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) { + if (error_message_out) { + *error_message_out = "Failed copying package: " + ec.message(); + } + return false; + } + + if (HasContentSidecarDataDirectory(package_info.path)) { + std::filesystem::path source_sidecar = package_info.path; + source_sidecar += ".data"; + std::filesystem::path destination_sidecar = destination_file; + destination_sidecar += ".data"; + if (!xe_copy_directory_recursive(source_sidecar, destination_sidecar, error_message_out)) { + return false; + } + } + + return true; +} + +void xe_collect_installed_content(const std::filesystem::path& root, IOSInstalledContentKind kind, + std::vector* content_out) { + if (!content_out) { + return; + } + + std::error_code ec; + if (!std::filesystem::exists(root, ec)) { + return; + } + + std::filesystem::directory_iterator it(root, ec); + std::filesystem::directory_iterator end; + for (; !ec && it != end; ++it) { + if (!it->is_directory(ec) || ec) { + ec.clear(); + continue; + } + + IOSInstalledContentEntry entry; + entry.kind = kind; + entry.name = it->path().filename().string(); + entry.path = it->path(); + content_out->push_back(std::move(entry)); + } +} + +std::vector xe_list_installed_content(uint32_t title_id) { + std::vector content; + if (!title_id) { + return content; + } + + xe_collect_installed_content(xe_title_update_content_root(title_id), + IOSInstalledContentKind::kTitleUpdate, &content); + xe_collect_installed_content(xe_dlc_content_root(title_id), IOSInstalledContentKind::kDlc, + &content); + std::sort(content.begin(), content.end(), + [](const IOSInstalledContentEntry& a, const IOSInstalledContentEntry& b) { + if (a.kind != b.kind) { + return a.kind < b.kind; + } + return a.name < b.name; + }); + return content; +} diff --git a/src/xenia/ui/ios/launcher/ios_document_import_coordinator.h b/src/xenia/ui/ios/launcher/ios_document_import_coordinator.h new file mode 100644 index 000000000..9f99fd4be --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_document_import_coordinator.h @@ -0,0 +1,51 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_DOCUMENT_IMPORT_COORDINATOR_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_DOCUMENT_IMPORT_COORDINATOR_H_ + +#ifdef __OBJC__ + +#import + +#include +#include + +@protocol XeniaIOSDocumentImportCoordinatorHost + +- (UIViewController*)documentImportCoordinatorPresenter; +- (BOOL)documentImportCoordinatorGameStopInProgress; +- (BOOL)documentImportCoordinatorJITAcquired; +- (void)documentImportCoordinatorSetStatusText:(NSString*)text; +- (std::filesystem::path)documentImportCoordinatorImportGameAtURL:(NSURL*)sourceURL + error:(NSError**)error; +- (void)documentImportCoordinatorRefreshImportedGames; +- (void)documentImportCoordinatorLaunchGameAtPath:(const std::filesystem::path&)gamePath + displayName:(NSString*)displayName; +- (BOOL)documentImportCoordinatorCanInstallTitleUpdates; +- (BOOL)documentImportCoordinatorInstallTitleUpdateAtPath:(const std::filesystem::path&)path + status:(std::string*)statusOut + notTitleUpdate:(bool*)notTitleUpdateOut; +- (void)documentImportCoordinatorPresentAlertWithTitle:(NSString*)title message:(NSString*)message; +- (void)documentImportCoordinatorImportTouchLayoutAtURL:(NSURL*)url; +- (void)documentImportCoordinatorTouchLayoutImportCancelled; + +@end + +@interface XeniaIOSDocumentImportCoordinator : NSObject + +- (instancetype)initWithHost:(id)host; +- (void)presentGameImportPicker; +- (void)presentTouchLayoutImportPickerFromViewController:(UIViewController*)presenter; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_DOCUMENT_IMPORT_COORDINATOR_H_ diff --git a/src/xenia/ui/ios/launcher/ios_document_import_coordinator.mm b/src/xenia/ui/ios/launcher/ios_document_import_coordinator.mm new file mode 100644 index 000000000..9a4eed367 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_document_import_coordinator.mm @@ -0,0 +1,189 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_document_import_coordinator.h" + +#import + +#include + +#include "xenia/base/logging.h" + +#import "xenia/ui/ios/launcher/ios_content_management.h" +#import "xenia/ui/ios/launcher/ios_game_library.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +enum class IOSDocumentImportMode : uint8_t { + kGameImport = 0, + kTouchLayoutImport, +}; + +} // namespace + +@implementation XeniaIOSDocumentImportCoordinator { + id _host; + IOSDocumentImportMode _mode; +} + +- (instancetype)initWithHost:(id)host { + if (!(self = [super init])) { + return nil; + } + _host = host; + _mode = IOSDocumentImportMode::kGameImport; + return self; +} + +- (void)presentGameImportPicker { + _mode = IOSDocumentImportMode::kGameImport; + if ([_host documentImportCoordinatorGameStopInProgress]) { + [_host documentImportCoordinatorSetStatusText:@"Stopping game... Please wait."]; + return; + } + + NSArray* content_types = @[ + [UTType typeWithFilenameExtension:@"iso"], + [UTType typeWithFilenameExtension:@"xex"], + UTTypeData, + ]; + + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:content_types]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + picker.shouldShowFileExtensions = YES; + [[_host documentImportCoordinatorPresenter] presentViewController:picker + animated:YES + completion:nil]; + [picker release]; +} + +- (void)presentTouchLayoutImportPickerFromViewController:(UIViewController*)presenter { + _mode = IOSDocumentImportMode::kTouchLayoutImport; + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[ UTTypeData ]]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + picker.shouldShowFileExtensions = YES; + [presenter presentViewController:picker animated:YES completion:nil]; + [picker release]; +} + +- (void)importGameAtURL:(NSURL*)url { + BOOL access_granted = [url startAccessingSecurityScopedResource]; + XELOGI("iOS: User selected game file: {} (security-scoped: {})", [url.path UTF8String], + access_granted ? "yes" : "no"); + + void (^import_selected_game)(void) = ^{ + NSError* import_error = nil; + std::filesystem::path imported_path = + [_host documentImportCoordinatorImportGameAtURL:url error:&import_error]; + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + + if (imported_path.empty()) { + NSString* message = import_error.localizedDescription ?: @"Failed to import selected game."; + [_host documentImportCoordinatorPresentAlertWithTitle:@"Import Failed" message:message]; + return; + } + + [_host documentImportCoordinatorRefreshImportedGames]; + NSString* imported_name = ToNSString(imported_path.filename().string()); + if ([_host documentImportCoordinatorJITAcquired]) { + [_host documentImportCoordinatorLaunchGameAtPath:imported_path displayName:imported_name]; + } else { + [_host documentImportCoordinatorSetStatusText: + [NSString stringWithFormat:@"Imported %@. Waiting for JIT.", imported_name]]; + } + }; + + const std::filesystem::path selected_path([url.path UTF8String]); + const BOOL likely_direct_game = + xe::ui::IsISOPath(selected_path) || xe::ui::IsDefaultXexPath(selected_path); + IOSSelectedContentPackage package_info; + const BOOL has_content_package_info = + xe_read_selected_content_package(selected_path, &package_info, nullptr); + const BOOL is_launchable_package = + has_content_package_info && xe::ui::IsIOSLaunchableContentType(package_info.content_type); + const BOOL should_try_title_update_install = + [_host documentImportCoordinatorCanInstallTitleUpdates] && !likely_direct_game && + !is_launchable_package; + if (should_try_title_update_install) { + [_host documentImportCoordinatorSetStatusText:@"Checking content package..."]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + std::string status; + bool not_title_update = false; + bool success = [_host documentImportCoordinatorInstallTitleUpdateAtPath:selected_path + status:&status + notTitleUpdate:¬_title_update]; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (success) { + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + NSString* message = status.empty() ? @"Installed title update." : ToNSString(status); + [_host documentImportCoordinatorSetStatusText:message]; + [_host documentImportCoordinatorRefreshImportedGames]; + [_host documentImportCoordinatorPresentAlertWithTitle:@"Title Update Installed" + message:message]; + return; + } + + if (!not_title_update) { + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + NSString* message = + status.empty() ? @"Title update installation failed." : ToNSString(status); + [_host documentImportCoordinatorSetStatusText:message]; + [_host documentImportCoordinatorPresentAlertWithTitle:@"Installation Failed" + message:message]; + return; + } + + import_selected_game(); + }); + }); + return; + } + + import_selected_game(); +} + +#pragma mark - UIDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController* __unused)controller + didPickDocumentsAtURLs:(NSArray*)urls { + if (urls.count == 0) { + return; + } + + NSURL* url = urls[0]; + if (_mode == IOSDocumentImportMode::kTouchLayoutImport) { + _mode = IOSDocumentImportMode::kGameImport; + [_host documentImportCoordinatorImportTouchLayoutAtURL:url]; + return; + } + + [self importGameAtURL:url]; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController* __unused)controller { + XELOGI("iOS: Document picker cancelled"); + if (_mode == IOSDocumentImportMode::kTouchLayoutImport) { + _mode = IOSDocumentImportMode::kGameImport; + [_host documentImportCoordinatorTouchLayoutImportCancelled]; + } +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_external_url.h b/src/xenia/ui/ios/launcher/ios_external_url.h new file mode 100644 index 000000000..964ae47dd --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_external_url.h @@ -0,0 +1,38 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_EXTERNAL_URL_H_ +#define XENIA_UI_IOS_EXTERNAL_URL_H_ + +#import + +#include +#include + +#include "xenia/ui/ios/app/windowed_app_context_ios.h" + +namespace xe { +namespace ui { + +NSString* DecodeURLComponent(NSString* value); +NSString* NormalizeURLToken(NSString* value); +bool ExtractLaunchPathFromExternalURL(NSURL* url, std::filesystem::path* path_out); +bool IsExternalGameInfoRequestURL(NSURL* url, NSString** callback_scheme_out); +bool ExtractLaunchTitleIDFromExternalURL(NSURL* url, uint32_t* title_id_out, + IOSGameSystem* system_out, bool* system_present_out); + +NSString* xe_game_system_url_value(IOSGameSystem system); +NSString* xe_launch_url_for_title_id(uint32_t title_id, IOSGameSystem system); +NSString* xe_game_info_callback_provider(NSURL* request_url); +NSURL* xe_stikdebug_enable_jit_url_for_bundle_identifier(NSString* bundle_identifier); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_EXTERNAL_URL_H_ diff --git a/src/xenia/ui/ios/launcher/ios_external_url.mm b/src/xenia/ui/ios/launcher/ios_external_url.mm new file mode 100644 index 000000000..100a9fc8d --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_external_url.mm @@ -0,0 +1,333 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/launcher/ios_external_url.h" + +#include +#include + +#include "xenia/ui/ios/launcher/ios_game_library.h" +#include "xenia/ui/ios/shared/ios_system_utils.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace xe { +namespace ui { +namespace { + +NSString* URLQueryItemValueCaseInsensitive( + NSURLComponents* components, NSArray* candidate_keys) { + if (!components || candidate_keys.count == 0) { + return nil; + } + for (NSString* key in candidate_keys) { + for (NSURLQueryItem* item in components.queryItems) { + if (!item.name || [item.name caseInsensitiveCompare:key] != NSOrderedSame) { + continue; + } + NSString* value = NormalizeURLToken(item.value); + if (value.length > 0) { + return value; + } + } + } + return nil; +} + +NSString* ExternalURLActionName(NSURL* url) { + if (!url) { + return nil; + } + NSString* host = NormalizeURLToken(url.host); + if (host.length > 0) { + return [host lowercaseString]; + } + for (NSString* path_component in url.pathComponents) { + NSString* component = NormalizeURLToken(path_component); + if (!component || [component isEqualToString:@"/"]) { + continue; + } + return [component lowercaseString]; + } + return nil; +} + +bool BuildLaunchPathFromURLValue(NSString* value, + std::filesystem::path* path_out) { + if (!path_out) { + return false; + } + NSString* normalized = DecodeURLComponent(value); + if (!normalized || normalized.length == 0) { + return false; + } + + NSURL* nested_url = [NSURL URLWithString:normalized]; + if (nested_url && nested_url.isFileURL) { + normalized = nested_url.path; + } + if (!normalized || normalized.length == 0 || + [normalized isEqualToString:@"/"]) { + return false; + } + + if ([normalized hasPrefix:@"private/"]) { + normalized = [@"/" stringByAppendingString:normalized]; + } else if (![normalized hasPrefix:@"/"]) { + normalized = [ToNSString(xe_get_ios_documents_path().string()) + stringByAppendingPathComponent:normalized]; + } + + *path_out = std::filesystem::path([normalized UTF8String]).lexically_normal(); + return !path_out->empty(); +} + +bool ParseTitleIDFromURLValue(NSString* value, uint32_t* title_id_out) { + if (!value || !title_id_out) { + return false; + } + NSString* normalized = DecodeURLComponent(value); + if (!normalized) { + return false; + } + normalized = [normalized + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([normalized hasPrefix:@"0x"] || [normalized hasPrefix:@"0X"]) { + normalized = [normalized substringFromIndex:2]; + } + if (normalized.length != 8) { + return false; + } + const char* utf8 = [normalized UTF8String]; + if (!utf8 || !utf8[0]) { + return false; + } + char* end = nullptr; + errno = 0; + unsigned long parsed = std::strtoul(utf8, &end, 16); + if (errno != 0 || !end || *end != '\0' || parsed > UINT32_MAX || + parsed == 0) { + return false; + } + *title_id_out = static_cast(parsed); + return true; +} + +bool ParseGameSystemFromURLValue(NSString* value, IOSGameSystem* system_out) { + if (!value || !system_out) { + return false; + } + NSString* normalized = NormalizeURLToken(value); + if (!normalized) { + return false; + } + normalized = [normalized lowercaseString]; + if ([normalized isEqualToString:@"xbox360"] || + [normalized isEqualToString:@"360"] || + [normalized isEqualToString:@"xenia"]) { + *system_out = IOSGameSystem::kXbox360; + return true; + } + return false; +} + +} // namespace + +NSString* DecodeURLComponent(NSString* value) { + if (!value || value.length == 0) { + return nil; + } + NSString* decoded = [value stringByRemovingPercentEncoding]; + if (decoded && decoded.length > 0) { + return decoded; + } + return value; +} + +NSString* NormalizeURLToken(NSString* value) { + NSString* decoded = DecodeURLComponent(value); + if (!decoded) { + return nil; + } + NSString* trimmed = + [decoded stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + return trimmed.length > 0 ? trimmed : nil; +} + +bool ExtractLaunchPathFromExternalURL(NSURL* url, + std::filesystem::path* path_out) { + if (!url || !path_out) { + return false; + } + + if (url.isFileURL) { + const char* url_path = [url.path UTF8String]; + if (url_path && url_path[0]) { + *path_out = std::filesystem::path(url_path).lexically_normal(); + return true; + } + } + + NSURLComponents* components = + [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + if (components) { + NSArray* query_items = components.queryItems; + NSArray* candidate_keys = @[ @"path", @"file", @"game", @"rom", @"url" ]; + for (NSString* key in candidate_keys) { + for (NSURLQueryItem* item in query_items) { + if (!item.name || [item.name caseInsensitiveCompare:key] != NSOrderedSame) { + continue; + } + if (BuildLaunchPathFromURLValue(item.value, path_out)) { + return true; + } + } + } + } + + if (BuildLaunchPathFromURLValue(url.path, path_out)) { + return true; + } + + BOOL host_looks_like_path = NO; + if (url.host && url.host.length > 0) { + host_looks_like_path = [url.host hasPrefix:@"/"] || + [url.host hasPrefix:@"private/"] || + [url.host hasPrefix:@"%2F"] || + [url.host hasPrefix:@"%2f"]; + } + if (host_looks_like_path && + (!url.path || url.path.length == 0 || [url.path isEqualToString:@"/"]) && + BuildLaunchPathFromURLValue(url.host, path_out)) { + return true; + } + + return false; +} + +bool IsExternalGameInfoRequestURL(NSURL* url, NSString** callback_scheme_out) { + if (callback_scheme_out) { + *callback_scheme_out = nil; + } + if (!url || url.isFileURL) { + return false; + } + NSString* action = ExternalURLActionName(url); + if (!action || [action caseInsensitiveCompare:@"gameinfo"] != NSOrderedSame) { + return false; + } + NSURLComponents* components = + [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + if (callback_scheme_out) { + *callback_scheme_out = URLQueryItemValueCaseInsensitive( + components, @[ @"scheme", @"callback-scheme", @"callback_scheme" ]); + } + return true; +} + +bool ExtractLaunchTitleIDFromExternalURL(NSURL* url, uint32_t* title_id_out, + IOSGameSystem* system_out, + bool* system_present_out) { + if (!url || !title_id_out || url.isFileURL) { + return false; + } + + if (system_out) { + *system_out = IOSGameSystem::kXbox360; + } + if (system_present_out) { + *system_present_out = false; + } + + NSURLComponents* components = + [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + if (components) { + if (system_out) { + NSArray* system_keys = @[ @"system", @"platform", @"core" ]; + for (NSString* key in system_keys) { + for (NSURLQueryItem* item in components.queryItems) { + if (!item.name || [item.name caseInsensitiveCompare:key] != NSOrderedSame) { + continue; + } + if (ParseGameSystemFromURLValue(item.value, system_out)) { + if (system_present_out) { + *system_present_out = true; + } + break; + } + } + } + } + NSArray* candidate_keys = @[ @"title-id", @"title_id", @"titleid", @"tid" ]; + for (NSString* key in candidate_keys) { + for (NSURLQueryItem* item in components.queryItems) { + if (!item.name || [item.name caseInsensitiveCompare:key] != NSOrderedSame) { + continue; + } + if (ParseTitleIDFromURLValue(item.value, title_id_out)) { + return true; + } + } + } + } + + NSArray* path_components = url.pathComponents; + for (NSString* component in [path_components reverseObjectEnumerator]) { + if (ParseTitleIDFromURLValue(component, title_id_out)) { + return true; + } + } + + return ParseTitleIDFromURLValue(url.host, title_id_out); +} + +NSString* xe_game_system_url_value(IOSGameSystem system) { + switch (system) { + case IOSGameSystem::kXbox360: + default: + return @"xbox360"; + } +} + +NSString* xe_launch_url_for_title_id(uint32_t title_id, IOSGameSystem system) { + if (!title_id) { + return nil; + } + NSURLComponents* components = [[[NSURLComponents alloc] init] autorelease]; + components.scheme = @"xenios"; + components.host = @"launch"; + components.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"title-id" value:ToNSString(FormatTitleID(title_id))], + [NSURLQueryItem queryItemWithName:@"system" value:xe_game_system_url_value(system)] + ]; + return components.URL.absoluteString; +} + +NSString* xe_game_info_callback_provider(NSURL* request_url) { + NSString* scheme = NormalizeURLToken(request_url ? request_url.scheme : nil); + if (!scheme || scheme.length == 0) { + return @"xenios"; + } + return [scheme lowercaseString]; +} + +NSURL* xe_stikdebug_enable_jit_url_for_bundle_identifier( + NSString* bundle_identifier) { + if (!bundle_identifier || bundle_identifier.length == 0) { + return nil; + } + NSURLComponents* components = [[[NSURLComponents alloc] init] autorelease]; + components.scheme = @"stikjit"; + components.host = @"enable-jit"; + components.queryItems = @[ [NSURLQueryItem queryItemWithName:@"bundle-id" + value:bundle_identifier] ]; + return components.URL; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.h new file mode 100644 index 000000000..82a43869e --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.h @@ -0,0 +1,47 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_GAME_ACTIONS_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_GAME_ACTIONS_VIEW_CONTROLLER_H_ + +#ifdef __OBJC__ + +#import + +#include + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +typedef NS_ENUM(NSInteger, XeniaIOSGameAction) { + XeniaIOSGameActionPlay = 0, + XeniaIOSGameActionGameSettings, + XeniaIOSGameActionTouchLayout, + XeniaIOSGameActionCompatibility, + XeniaIOSGameActionManageContent, + XeniaIOSGameActionLaunchDisc, + XeniaIOSGameActionPatches, + XeniaIOSGameActionCopyLaunchURL, +}; + +@interface XeniaIOSGameActionsViewController : XESheetTableViewController + +@property(nonatomic, copy) void (^actionHandler)(XeniaIOSGameAction action); + +- (instancetype)initWithGameTitle:(NSString*)gameTitle + titleID:(uint32_t)titleID + supportsCompatibility:(BOOL)supportsCompatibility + supportsManageContent:(BOOL)supportsManageContent + supportsDiscSelection:(BOOL)supportsDiscSelection + supportsPatches:(BOOL)supportsPatches; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_GAME_ACTIONS_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.mm new file mode 100644 index 000000000..982114038 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_actions_view_controller.mm @@ -0,0 +1,166 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_actions_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +struct XeniaIOSGameActionRow { + XeniaIOSGameAction action; + NSString* title; + NSString* subtitle; + NSString* symbol; + BOOL enabled; +}; + +} // namespace + +@implementation XeniaIOSGameActionsViewController { + NSString* game_title_; + uint32_t title_id_; + BOOL supports_compatibility_; + BOOL supports_manage_content_; + BOOL supports_disc_selection_; + BOOL supports_patches_; + NSArray* rows_; + void (^action_handler_)(XeniaIOSGameAction action); +} + +@synthesize actionHandler = action_handler_; + +- (instancetype)initWithGameTitle:(NSString*)gameTitle + titleID:(uint32_t)titleID + supportsCompatibility:(BOOL)supportsCompatibility + supportsManageContent:(BOOL)supportsManageContent + supportsDiscSelection:(BOOL)supportsDiscSelection + supportsPatches:(BOOL)supportsPatches { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + game_title_ = [gameTitle copy]; + title_id_ = titleID; + supports_compatibility_ = supportsCompatibility; + supports_manage_content_ = supportsManageContent; + supports_disc_selection_ = supportsDiscSelection; + supports_patches_ = supportsPatches; + } + return self; +} + +- (void)dealloc { + [game_title_ release]; + [rows_ release]; + [action_handler_ release]; + [super dealloc]; +} + +- (NSArray*)buildRows { + XeniaIOSGameActionRow rows[] = { + {XeniaIOSGameActionPlay, @"Play", @"Launch the selected title.", @"play.fill", YES}, + {XeniaIOSGameActionGameSettings, @"Game Settings", + @"Save display, compatibility, and cvar overrides for this title.", + @"slider.horizontal.3", title_id_ != 0}, + {XeniaIOSGameActionTouchLayout, @"Touch Layout", + @"Edit or assign the touch controls used by this title.", @"hand.tap", title_id_ != 0}, + {XeniaIOSGameActionCompatibility, @"Compatibility", + @"Open compatibility status, reports, and notes.", @"checkmark.shield", + supports_compatibility_}, + {XeniaIOSGameActionManageContent, @"Manage Content", + @"Manage installed title updates and related content.", @"square.stack.3d.up", + supports_manage_content_}, + {XeniaIOSGameActionLaunchDisc, @"Launch Disc", + @"Pick which disc to start for multi-disc games.", @"opticaldisc", + supports_disc_selection_}, + {XeniaIOSGameActionPatches, @"Patches", @"View and manage title patches.", + @"puzzlepiece.extension", supports_patches_}, + {XeniaIOSGameActionCopyLaunchURL, @"Copy Launch URL", + @"Copy the xenia:// URL for this title.", @"link", title_id_ != 0}, + }; + + NSMutableArray* values = [NSMutableArray array]; + for (const XeniaIOSGameActionRow& row : rows) { + [values addObject:[NSValue valueWithBytes:&row objCType:@encode(XeniaIOSGameActionRow)]]; + } + return values; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Game Actions"; + if (game_title_.length) { + self.navigationItem.prompt = game_title_; + } + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + rows_ = [[self buildRows] retain]; + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancelTapped:)] autorelease]; +} + +- (void)cancelTapped:(id)sender { + (void)sender; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (XeniaIOSGameActionRow)rowAtIndexPath:(NSIndexPath*)indexPath { + XeniaIOSGameActionRow row = {}; + if (indexPath.section != 0 || indexPath.row < 0 || + indexPath.row >= static_cast(rows_.count)) { + return row; + } + [rows_[indexPath.row] getValue:&row]; + return row; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section { + return section == 0 ? static_cast(rows_.count) : 0; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaIOSGameActionCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + cell.backgroundColor = [UIColor secondarySystemGroupedBackgroundColor]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.textLabel.numberOfLines = 1; + cell.detailTextLabel.numberOfLines = 2; + } + + XeniaIOSGameActionRow row = [self rowAtIndexPath:indexPath]; + cell.textLabel.text = row.title; + cell.detailTextLabel.text = row.subtitle; + cell.imageView.image = [UIImage systemImageNamed:row.symbol]; + cell.userInteractionEnabled = row.enabled; + cell.accessoryType = + row.enabled ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; + cell.textLabel.textColor = row.enabled ? [XeniaTheme textPrimary] : [XeniaTheme textMuted]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.imageView.tintColor = row.enabled ? [XeniaTheme accent] : [XeniaTheme textMuted]; + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + XeniaIOSGameActionRow row = [self rowAtIndexPath:indexPath]; + if (!row.enabled || !action_handler_) { + return; + } + action_handler_(row.action); +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_art.h b/src/xenia/ui/ios/launcher/ios_game_art.h new file mode 100644 index 000000000..fb6725c24 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_art.h @@ -0,0 +1,33 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_ART_H_ +#define XENIA_UI_IOS_GAME_ART_H_ + +#import + +#include + +// Cover and background artwork cache for game tiles, hero cards and +// compatibility sheets. +// +// Tile (cover) artwork is fetched from Element18592/360-Game-Art keyed by the +// lower-case hex title id and stored in +// Library/Caches/game-art/{title_id_hex}.jpg. +// Background artwork is fetched from xenia-manager/x360db (upper-case hex) +// and stored in +// Library/Caches/game-background-art/{title_id_hex_lower}.jpg. + +UIImage* xe_cached_game_art(uint32_t title_id); +UIImage* xe_cached_game_background_art(uint32_t title_id); + +void xe_fetch_game_art(uint32_t title_id, void (^completion)(UIImage* _Nullable image)); +void xe_fetch_game_background_art(uint32_t title_id, void (^completion)(UIImage* _Nullable image)); + +#endif // XENIA_UI_IOS_GAME_ART_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_art.mm b/src/xenia/ui/ios/launcher/ios_game_art.mm new file mode 100644 index 000000000..2de0a2b5f --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_art.mm @@ -0,0 +1,204 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_art.h" + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +NSString* xe_game_art_cache_dir(void) { + static NSString* dir; + if (!dir) { + NSString* caches = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + dir = [[caches stringByAppendingPathComponent:@"game-art"] retain]; // MRC + [[NSFileManager defaultManager] createDirectoryAtPath:dir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + return dir; +} + +NSString* xe_game_art_hex(uint32_t title_id) { return XEFormatTitleIDHexLower(title_id); } + +NSMutableSet* xe_game_art_inflight_ids(void) { + static NSMutableSet* inflight; + if (!inflight) { + inflight = [[NSMutableSet alloc] init]; + } + return inflight; +} + +NSMutableDictionary* xe_game_art_retry_after(void) { + static NSMutableDictionary* retry_after; + if (!retry_after) { + retry_after = [[NSMutableDictionary alloc] init]; + } + return retry_after; +} + +bool xe_begin_game_art_fetch(NSString* hex) { + if (!hex.length) { + return false; + } + NSMutableSet* inflight = xe_game_art_inflight_ids(); + if ([inflight containsObject:hex]) { + return false; + } + NSDate* retry_after = [xe_game_art_retry_after() objectForKey:hex]; + if (retry_after && [retry_after timeIntervalSinceNow] > 0) { + return false; + } + [inflight addObject:hex]; + return true; +} + +void xe_complete_game_art_fetch(NSString* hex, bool success) { + if (!hex.length) { + return; + } + [xe_game_art_inflight_ids() removeObject:hex]; + NSMutableDictionary* retry_after = xe_game_art_retry_after(); + if (success) { + [retry_after removeObjectForKey:hex]; + } else { + // Throttle repeated failed downloads while still allowing periodic retries. + [retry_after setObject:[NSDate dateWithTimeIntervalSinceNow:300.0] forKey:hex]; + } +} + +NSString* xe_game_background_cache_dir(void) { + static NSString* dir; + if (!dir) { + NSString* caches = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + dir = [[caches stringByAppendingPathComponent:@"game-background-art"] retain]; + [[NSFileManager defaultManager] createDirectoryAtPath:dir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + return dir; +} + +} // namespace + +UIImage* xe_cached_game_art(uint32_t title_id) { + if (!title_id) { + return nil; + } + NSString* path = [xe_game_art_cache_dir() + stringByAppendingPathComponent:[xe_game_art_hex(title_id) stringByAppendingString:@".jpg"]]; + return [UIImage imageWithContentsOfFile:path]; +} + +UIImage* xe_cached_game_background_art(uint32_t title_id) { + if (!title_id) { + return nil; + } + NSString* path = [xe_game_background_cache_dir() + stringByAppendingPathComponent:[xe_game_art_hex(title_id) stringByAppendingString:@".jpg"]]; + return [UIImage imageWithContentsOfFile:path]; +} + +void xe_fetch_game_art(uint32_t title_id, void (^completion)(UIImage* _Nullable image)) { + if (!title_id) { + if (completion) { + completion(nil); + } + return; + } + NSString* hex = xe_game_art_hex(title_id); + if (!xe_begin_game_art_fetch(hex)) { + return; + } + NSString* url_str = [NSString + stringWithFormat: + @"https://raw.githubusercontent.com/Element18592/360-Game-Art/main/Games/%@/cover.jpg", + hex]; + NSURL* url = [NSURL URLWithString:url_str]; + if (!url) { + dispatch_async(dispatch_get_main_queue(), ^{ + xe_complete_game_art_fetch(hex, false); + if (completion) { + completion(nil); + } + }); + return; + } + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithURL:url + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + UIImage* image = nil; + if (!error && data.length > 0) { + NSHTTPURLResponse* http = (NSHTTPURLResponse*)response; + if ([http isKindOfClass:[NSHTTPURLResponse class]] && http.statusCode == 200) { + image = [UIImage imageWithData:data]; + if (image) { + NSString* path = [xe_game_art_cache_dir() + stringByAppendingPathComponent:[hex stringByAppendingString:@".jpg"]]; + [data writeToFile:path atomically:YES]; + } + } + } + const bool success = image != nil; + dispatch_async(dispatch_get_main_queue(), ^{ + xe_complete_game_art_fetch(hex, success); + if (completion) { + completion(image); + } + }); + }]; + [task resume]; +} + +void xe_fetch_game_background_art(uint32_t title_id, void (^completion)(UIImage* _Nullable image)) { + if (!title_id) { + if (completion) { + completion(nil); + } + return; + } + NSString* hex_upper = XEFormatTitleIDHexUpper(title_id); + NSString* url_str = [NSString stringWithFormat:@"https://raw.githubusercontent.com/xenia-manager/" + @"x360db/main/titles/%@/artwork/background.jpg", + hex_upper]; + NSURL* url = [NSURL URLWithString:url_str]; + if (!url) { + if (completion) { + completion(nil); + } + return; + } + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithURL:url + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + UIImage* image = nil; + if (!error && data.length > 0) { + NSHTTPURLResponse* http = (NSHTTPURLResponse*)response; + if ([http isKindOfClass:[NSHTTPURLResponse class]] && http.statusCode == 200) { + image = [UIImage imageWithData:data]; + if (image) { + NSString* path = [xe_game_background_cache_dir() + stringByAppendingPathComponent:[xe_game_art_hex(title_id) + stringByAppendingString:@".jpg"]]; + [data writeToFile:path atomically:YES]; + } + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) { + completion(image); + } + }); + }]; + [task resume]; +} diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.h b/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.h new file mode 100644 index 000000000..ce6fbda17 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.h @@ -0,0 +1,31 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_CELLS_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_CELLS_H_ + +#import + +@interface XeniaGameCompatibilityCells : NSObject ++ (UITableViewCell*)detailsCellWithCompatInfo:(NSDictionary*)compat_info + latestReport:(NSDictionary*)latest_report; ++ (UITableViewCell*)ctaCellWithTarget:(id)target submitAction:(SEL)submit_action; ++ (UITableViewCell*)discussionCellWithReports:(NSArray*)reports + expandedReportIndexes:(NSSet*)expanded_report_indexes + loading:(BOOL)loading + showAll:(BOOL)show_all + issueURL:(NSString*)issue_url + issueNumber:(NSInteger)issue_number + target:(id)target + viewIssueAction:(SEL)view_issue_action + toggleExpansionAction:(SEL)toggle_expansion_action + toggleReportAction:(SEL)toggle_report_action; +@end + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_CELLS_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.mm b/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.mm new file mode 100644 index 000000000..f1ef58ddc --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_cells.mm @@ -0,0 +1,635 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_compatibility_cells.h" + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +constexpr NSInteger kXeniaDiscussionPreviewCount = 3; + +UIView* XeniaCompatCardViewForCell(UITableViewCell* cell) { + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [XeniaTheme bgSurface]; + card.layer.cornerRadius = XeniaRadiusXl; + card.layer.borderWidth = 0.5; + card.layer.borderColor = [XeniaTheme border].CGColor; + [cell.contentView addSubview:card]; + [NSLayoutConstraint activateConstraints:@[ + [card.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:6], + [card.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16], + [card.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16], + [card.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-6], + ]]; + return card; +} + +UIView* XeniaCompatBuildMetadataPillRowForEntry(NSDictionary* entry) { + NSDictionary* build_info = xe_compat_build_info_from_entry(entry); + if (!build_info) { + return nil; + } + + UIStackView* stack = [[[UIStackView alloc] init] autorelease]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + stack.axis = UILayoutConstraintAxisHorizontal; + stack.spacing = 8.0; + stack.alignment = UIStackViewAlignmentLeading; + stack.distribution = UIStackViewDistributionFillProportionally; + + NSString* channel = xe_string_from_object(build_info[@"channel"]); + if (channel.length > 0) { + [stack addArrangedSubview:xe_make_tag_pill(xe_compat_channel_label(channel), + xe_compat_channel_color(channel))]; + } + + NSString* build_label = xe_compat_build_label(build_info); + if (build_label.length > 0) { + [stack addArrangedSubview:xe_make_tag_pill(build_label, [XeniaTheme textSecondary])]; + } + + if (stack.arrangedSubviews.count == 0) { + return nil; + } + + UIView* row = [[[UIView alloc] init] autorelease]; + row.translatesAutoresizingMaskIntoConstraints = NO; + [row addSubview:stack]; + [NSLayoutConstraint activateConstraints:@[ + [stack.topAnchor constraintEqualToAnchor:row.topAnchor], + [stack.leadingAnchor constraintEqualToAnchor:row.leadingAnchor], + [stack.trailingAnchor constraintLessThanOrEqualToAnchor:row.trailingAnchor], + [stack.bottomAnchor constraintEqualToAnchor:row.bottomAnchor], + ]]; + return row; +} + +UIView* XeniaCompatDetailsMetricTile(NSString* label, NSString* value, UIColor* value_color, + BOOL value_is_pill) { + UIView* tile = [[[UIView alloc] init] autorelease]; + tile.translatesAutoresizingMaskIntoConstraints = NO; + tile.backgroundColor = [XeniaTheme bgPrimary]; + tile.layer.cornerRadius = XeniaRadiusMd; + tile.layer.borderWidth = 0.5; + tile.layer.borderColor = [XeniaTheme border].CGColor; + + UILabel* title = [[[UILabel alloc] init] autorelease]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.text = label; + title.font = [UIFont systemFontOfSize:11 weight:UIFontWeightSemibold]; + title.textColor = [XeniaTheme textMuted]; + [tile addSubview:title]; + + UIView* value_view = nil; + if (value_is_pill) { + value_view = xe_make_tag_pill(value ?: @"Unknown", value_color ?: [XeniaTheme textMuted]); + } else { + UILabel* value_label = [[[UILabel alloc] init] autorelease]; + value_label.translatesAutoresizingMaskIntoConstraints = NO; + value_label.text = value ?: @"Unknown"; + value_label.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + value_label.textColor = value_color ?: [XeniaTheme textPrimary]; + value_label.numberOfLines = 1; + value_view = value_label; + } + [tile addSubview:value_view]; + + [NSLayoutConstraint activateConstraints:@[ + [tile.heightAnchor constraintGreaterThanOrEqualToConstant:86.0], + [title.topAnchor constraintEqualToAnchor:tile.topAnchor constant:12.0], + [title.leadingAnchor constraintEqualToAnchor:tile.leadingAnchor constant:12.0], + [title.trailingAnchor constraintLessThanOrEqualToAnchor:tile.trailingAnchor constant:-12.0], + [value_view.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:10.0], + [value_view.leadingAnchor constraintEqualToAnchor:tile.leadingAnchor constant:12.0], + [value_view.trailingAnchor constraintLessThanOrEqualToAnchor:tile.trailingAnchor constant:-12.0], + [value_view.bottomAnchor constraintLessThanOrEqualToAnchor:tile.bottomAnchor constant:-12.0], + ]]; + + return tile; +} + +UIView* XeniaCompatDiscussionPreviewCardForReport(NSDictionary* report, NSInteger report_index, + NSSet* expanded_report_indexes, + NSString* issue_url, id target, + SEL view_issue_action, + SEL toggle_report_action) { + NSString* author = [report[@"submittedBy"] isKindOfClass:[NSString class]] + ? report[@"submittedBy"] + : @"anonymous"; + NSString* notes = [report[@"notes"] isKindOfClass:[NSString class]] ? report[@"notes"] : @""; + NSString* date_string = [report[@"date"] isKindOfClass:[NSString class]] ? report[@"date"] : @""; + NSString* formatted_date = + date_string.length >= 10 ? xe_format_iso_date(date_string) : date_string; + + if (![notes isKindOfClass:[NSString class]] || notes.length == 0) { + notes = @"No details provided."; + } + + NSMutableArray* info_parts = [NSMutableArray array]; + NSString* status = [report[@"status"] isKindOfClass:[NSString class]] ? report[@"status"] : nil; + if (status.length > 0) { + [info_parts addObject:xe_compat_status_label(status)]; + } + NSString* report_device = [report[@"deviceMachine"] isKindOfClass:[NSString class]] + ? report[@"deviceMachine"] + : report[@"device"]; + if ([report_device isKindOfClass:[NSString class]] && report_device.length > 0) { + [info_parts addObject:xe_device_display_name_for_machine(report_device)]; + } + NSString* platform_display = xe_platform_display_text(report[@"platform"], report[@"osVersion"]); + if (platform_display.length > 0) { + [info_parts addObject:platform_display]; + } + NSString* gpu_backend = + [report[@"gpuBackend"] isKindOfClass:[NSString class]] ? report[@"gpuBackend"] : nil; + if (gpu_backend.length > 0) { + [info_parts addObject:[gpu_backend uppercaseString]]; + } + NSString* info_text = [info_parts componentsJoinedByString:@" · "]; + + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [XeniaTheme bgPrimary]; + card.layer.cornerRadius = XeniaRadiusMd; + card.layer.borderWidth = 0.5; + card.layer.borderColor = [XeniaTheme border].CGColor; + + UILabel* author_label = [[[UILabel alloc] init] autorelease]; + author_label.translatesAutoresizingMaskIntoConstraints = NO; + author_label.text = author.length > 0 ? author : @"anonymous"; + author_label.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + author_label.textColor = [XeniaTheme textPrimary]; + [card addSubview:author_label]; + + UILabel* date_label = [[[UILabel alloc] init] autorelease]; + date_label.translatesAutoresizingMaskIntoConstraints = NO; + date_label.text = formatted_date; + date_label.font = [UIFont systemFontOfSize:13]; + date_label.textColor = [XeniaTheme textMuted]; + date_label.textAlignment = NSTextAlignmentRight; + [date_label setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [date_label setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [card addSubview:date_label]; + + UIView* build_row = XeniaCompatBuildMetadataPillRowForEntry(report); + if (build_row) { + [card addSubview:build_row]; + } + + UILabel* info_label = [[[UILabel alloc] init] autorelease]; + info_label.translatesAutoresizingMaskIntoConstraints = NO; + info_label.text = info_text; + info_label.font = [UIFont systemFontOfSize:13]; + info_label.textColor = [XeniaTheme textMuted]; + info_label.numberOfLines = 2; + info_label.hidden = info_label.text.length == 0; + [card addSubview:info_label]; + + UILabel* notes_label = [[[UILabel alloc] init] autorelease]; + notes_label.translatesAutoresizingMaskIntoConstraints = NO; + notes_label.text = notes; + notes_label.font = [UIFont systemFontOfSize:15]; + notes_label.textColor = [XeniaTheme textSecondary]; + BOOL report_expanded = + [expanded_report_indexes containsObject:[NSNumber numberWithInteger:report_index]]; + notes_label.numberOfLines = report_expanded ? 0 : 3; + notes_label.lineBreakMode = + report_expanded ? NSLineBreakByWordWrapping : NSLineBreakByTruncatingTail; + [card addSubview:notes_label]; + + BOOL can_expand_notes = notes.length > 170 || [notes rangeOfString:@"\n"].location != NSNotFound; + UIButton* expand_notes_button = [UIButton buttonWithType:UIButtonTypeSystem]; + expand_notes_button.translatesAutoresizingMaskIntoConstraints = NO; + [expand_notes_button setTitle:(report_expanded ? @"Show less" : @"Show more") + forState:UIControlStateNormal]; + [expand_notes_button setTitleColor:[XeniaTheme accent] forState:UIControlStateNormal]; + expand_notes_button.titleLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold]; + expand_notes_button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + expand_notes_button.tag = report_index; + expand_notes_button.hidden = !can_expand_notes; + [expand_notes_button addTarget:target + action:toggle_report_action + forControlEvents:UIControlEventTouchUpInside]; + [card addSubview:expand_notes_button]; + + UIButton* open_comment_button = [UIButton buttonWithType:UIButtonTypeSystem]; + open_comment_button.translatesAutoresizingMaskIntoConstraints = NO; + [open_comment_button setTitle:@"Open comment" forState:UIControlStateNormal]; + [open_comment_button setTitleColor:[XeniaTheme accent] forState:UIControlStateNormal]; + open_comment_button.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold]; + open_comment_button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + open_comment_button.hidden = (issue_url == nil); + [open_comment_button addTarget:target + action:view_issue_action + forControlEvents:UIControlEventTouchUpInside]; + [card addSubview:open_comment_button]; + + NSMutableArray* constraints = [NSMutableArray arrayWithArray:@[ + [author_label.topAnchor constraintEqualToAnchor:card.topAnchor constant:12], + [author_label.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [date_label.firstBaselineAnchor constraintEqualToAnchor:author_label.firstBaselineAnchor], + [date_label.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-10], + [date_label.leadingAnchor constraintGreaterThanOrEqualToAnchor:author_label.trailingAnchor + constant:8], + [notes_label.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [notes_label.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-10], + [expand_notes_button.topAnchor constraintEqualToAnchor:notes_label.bottomAnchor constant:4], + [expand_notes_button.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [expand_notes_button.trailingAnchor constraintLessThanOrEqualToAnchor:card.trailingAnchor + constant:-10], + [info_label.topAnchor constraintEqualToAnchor:expand_notes_button.bottomAnchor constant:6], + [info_label.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [info_label.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-10], + [open_comment_button.topAnchor constraintEqualToAnchor:info_label.bottomAnchor constant:8], + [open_comment_button.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [open_comment_button.trailingAnchor constraintLessThanOrEqualToAnchor:card.trailingAnchor + constant:-10], + [open_comment_button.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-10], + ]]; + if (build_row) { + [constraints addObjectsFromArray:@[ + [build_row.topAnchor constraintEqualToAnchor:author_label.bottomAnchor constant:8], + [build_row.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:10], + [build_row.trailingAnchor constraintLessThanOrEqualToAnchor:card.trailingAnchor constant:-10], + [notes_label.topAnchor constraintEqualToAnchor:build_row.bottomAnchor constant:8], + ]]; + } else { + [constraints addObject:[notes_label.topAnchor constraintEqualToAnchor:author_label.bottomAnchor + constant:6]]; + } + [NSLayoutConstraint activateConstraints:constraints]; + if (expand_notes_button.hidden) { + [expand_notes_button.heightAnchor constraintEqualToConstant:0].active = YES; + } + if (info_label.hidden) { + [info_label.heightAnchor constraintEqualToConstant:0].active = YES; + } + if (open_comment_button.hidden) { + [open_comment_button.heightAnchor constraintEqualToConstant:0].active = YES; + } + + return card; +} + +} // namespace + +@implementation XeniaGameCompatibilityCells + ++ (UITableViewCell*)detailsCellWithCompatInfo:(NSDictionary*)compat_info + latestReport:(NSDictionary*)latest_report { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.backgroundColor = [UIColor clearColor]; + cell.contentView.backgroundColor = [UIColor clearColor]; + + NSDictionary* summary_source = xe_preferred_summary_from_compat_info(compat_info); + BOOL using_release_summary = + [xe_string_from_object(summary_source[@"channel"]) isEqualToString:@"release"]; + NSDictionary* details_source = summary_source ?: latest_report; + + NSString* status = xe_string_from_object(details_source[@"status"]); + NSString* status_label = status.length > 0 ? xe_compat_status_label(status) : @"Unknown"; + UIColor* status_color = + status.length > 0 ? xe_compat_status_color(status) : [XeniaTheme textMuted]; + + NSString* report_device = [details_source[@"deviceMachine"] isKindOfClass:[NSString class]] + ? details_source[@"deviceMachine"] + : details_source[@"device"]; + NSString* device = + report_device.length > 0 ? xe_device_display_name_for_machine(report_device) : @"Unknown"; + + NSString* platform_display = + xe_platform_display_text(details_source[@"platform"], details_source[@"osVersion"]); + if (platform_display.length == 0) { + platform_display = @"Unknown"; + } + + NSString* gpu = [details_source[@"gpuBackend"] isKindOfClass:[NSString class]] + ? details_source[@"gpuBackend"] + : nil; + if (gpu.length == 0) { + gpu = @"Unknown"; + } else { + gpu = [gpu uppercaseString]; + } + + NSString* based_on_date = + [details_source[@"date"] isKindOfClass:[NSString class]] ? details_source[@"date"] : nil; + if (based_on_date.length == 0 && [compat_info[@"updatedAt"] isKindOfClass:[NSString class]]) { + based_on_date = compat_info[@"updatedAt"]; + } + NSString* footnote = @"Based on available compatibility data."; + if (using_release_summary && [status isEqualToString:@"untested"]) { + footnote = + @"No official release reports yet. Preview or self-built reports may still appear below."; + } else if (using_release_summary && based_on_date.length > 0) { + footnote = [NSString stringWithFormat:@"Based on the latest official release summary from %@.", + xe_format_iso_date(based_on_date)]; + } else if (using_release_summary) { + footnote = @"Based on the current official release summary."; + } else if (based_on_date.length > 0) { + footnote = [NSString + stringWithFormat:@"Based on the latest report from %@.", xe_format_iso_date(based_on_date)]; + } + + UIView* card = XeniaCompatCardViewForCell(cell); + + UILabel* heading = [[[UILabel alloc] init] autorelease]; + heading.translatesAutoresizingMaskIntoConstraints = NO; + heading.text = @"Details"; + heading.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + heading.textColor = [XeniaTheme textPrimary]; + [card addSubview:heading]; + + UILabel* subheading = [[[UILabel alloc] init] autorelease]; + subheading.translatesAutoresizingMaskIntoConstraints = NO; + subheading.text = + using_release_summary ? @"RELEASE SUMMARY" : (summary_source ? @"CURRENT SUMMARY" : @"LATEST REPORT"); + subheading.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold]; + subheading.textColor = [XeniaTheme textMuted]; + [card addSubview:subheading]; + + NSDictionary* build_entry = details_source; + if (!xe_compat_build_info_from_entry(build_entry) && compat_info && build_entry != compat_info) { + build_entry = compat_info; + } + UIView* build_row = XeniaCompatBuildMetadataPillRowForEntry(build_entry); + if (build_row) { + [card addSubview:build_row]; + } + + UIStackView* grid = [[[UIStackView alloc] init] autorelease]; + grid.translatesAutoresizingMaskIntoConstraints = NO; + grid.axis = UILayoutConstraintAxisVertical; + grid.spacing = 10.0; + [card addSubview:grid]; + + UIStackView* row_one = [[[UIStackView alloc] init] autorelease]; + row_one.axis = UILayoutConstraintAxisHorizontal; + row_one.spacing = 10.0; + row_one.distribution = UIStackViewDistributionFillEqually; + [grid addArrangedSubview:row_one]; + + UIStackView* row_two = [[[UIStackView alloc] init] autorelease]; + row_two.axis = UILayoutConstraintAxisHorizontal; + row_two.spacing = 10.0; + row_two.distribution = UIStackViewDistributionFillEqually; + [grid addArrangedSubview:row_two]; + + [row_one addArrangedSubview:XeniaCompatDetailsMetricTile(@"STATUS", status_label, status_color, + YES)]; + [row_one addArrangedSubview:XeniaCompatDetailsMetricTile(@"DEVICE", device, + [XeniaTheme textPrimary], NO)]; + [row_two addArrangedSubview:XeniaCompatDetailsMetricTile(@"PLATFORM", platform_display, + [XeniaTheme textPrimary], NO)]; + [row_two addArrangedSubview:XeniaCompatDetailsMetricTile(@"GPU", gpu, [XeniaTheme textPrimary], + NO)]; + + UILabel* footer = [[[UILabel alloc] init] autorelease]; + footer.translatesAutoresizingMaskIntoConstraints = NO; + footer.text = footnote; + footer.font = [UIFont systemFontOfSize:12]; + footer.textColor = [XeniaTheme textSecondary]; + footer.numberOfLines = 0; + [card addSubview:footer]; + + NSMutableArray* constraints = [NSMutableArray arrayWithArray:@[ + [heading.topAnchor constraintEqualToAnchor:card.topAnchor constant:16.0], + [heading.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [heading.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [subheading.topAnchor constraintEqualToAnchor:heading.bottomAnchor constant:12.0], + [subheading.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [subheading.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [grid.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [grid.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [footer.topAnchor constraintEqualToAnchor:grid.bottomAnchor constant:12.0], + [footer.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [footer.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [footer.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-14.0], + ]]; + if (build_row) { + [constraints addObjectsFromArray:@[ + [build_row.topAnchor constraintEqualToAnchor:subheading.bottomAnchor constant:8.0], + [build_row.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [build_row.trailingAnchor constraintLessThanOrEqualToAnchor:card.trailingAnchor + constant:-16.0], + [grid.topAnchor constraintEqualToAnchor:build_row.bottomAnchor constant:10.0], + ]]; + } else { + [constraints addObject:[grid.topAnchor constraintEqualToAnchor:subheading.bottomAnchor + constant:10.0]]; + } + [NSLayoutConstraint activateConstraints:constraints]; + + return cell; +} + ++ (UITableViewCell*)ctaCellWithTarget:(id)target submitAction:(SEL)submit_action { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.backgroundColor = [UIColor clearColor]; + cell.contentView.backgroundColor = [UIColor clearColor]; + + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [[XeniaTheme accent] colorWithAlphaComponent:0.04]; + card.layer.cornerRadius = XeniaRadiusXl; + card.layer.borderWidth = 1.0; + card.layer.borderColor = [[XeniaTheme accent] colorWithAlphaComponent:0.20].CGColor; + card.clipsToBounds = YES; + [cell.contentView addSubview:card]; + + UILabel* heading = [[[UILabel alloc] init] autorelease]; + heading.translatesAutoresizingMaskIntoConstraints = NO; + heading.text = @"Tested this game?"; + heading.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + heading.textColor = [XeniaTheme textPrimary]; + heading.textAlignment = NSTextAlignmentCenter; + [card addSubview:heading]; + + UILabel* subtext = [[[UILabel alloc] init] autorelease]; + subtext.translatesAutoresizingMaskIntoConstraints = NO; + subtext.text = @"Help the community by sharing how well this title runs on your device."; + subtext.font = [UIFont systemFontOfSize:14]; + subtext.textColor = [XeniaTheme textSecondary]; + subtext.numberOfLines = 0; + subtext.textAlignment = NSTextAlignmentCenter; + [card addSubview:subtext]; + + UIButton* submit_button = [UIButton buttonWithType:UIButtonTypeSystem]; + submit_button.translatesAutoresizingMaskIntoConstraints = NO; + [submit_button setTitle:@"Submit Report" forState:UIControlStateNormal]; + submit_button.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + [submit_button setTitleColor:[XeniaTheme accentFg] forState:UIControlStateNormal]; + submit_button.backgroundColor = [XeniaTheme accent]; + submit_button.layer.cornerRadius = XeniaRadiusMd; + submit_button.clipsToBounds = YES; + [submit_button addTarget:target action:submit_action forControlEvents:UIControlEventTouchUpInside]; + [card addSubview:submit_button]; + + [NSLayoutConstraint activateConstraints:@[ + [card.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:6.0], + [card.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16.0], + [card.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16.0], + [card.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-6.0], + [heading.topAnchor constraintEqualToAnchor:card.topAnchor constant:18.0], + [heading.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16.0], + [heading.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16.0], + [subtext.topAnchor constraintEqualToAnchor:heading.bottomAnchor constant:8.0], + [subtext.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:24.0], + [subtext.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-24.0], + [submit_button.topAnchor constraintEqualToAnchor:subtext.bottomAnchor constant:16.0], + [submit_button.centerXAnchor constraintEqualToAnchor:card.centerXAnchor], + [submit_button.heightAnchor constraintEqualToConstant:44.0], + [submit_button.widthAnchor constraintGreaterThanOrEqualToConstant:164.0], + [submit_button.leadingAnchor constraintGreaterThanOrEqualToAnchor:card.leadingAnchor + constant:16.0], + [submit_button.trailingAnchor constraintLessThanOrEqualToAnchor:card.trailingAnchor + constant:-16.0], + [submit_button.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-18.0], + ]]; + + return cell; +} + ++ (UITableViewCell*)discussionCellWithReports:(NSArray*)reports + expandedReportIndexes:(NSSet*)expanded_report_indexes + loading:(BOOL)loading + showAll:(BOOL)show_all + issueURL:(NSString*)issue_url + issueNumber:(NSInteger)issue_number + target:(id)target + viewIssueAction:(SEL)view_issue_action + toggleExpansionAction:(SEL)toggle_expansion_action + toggleReportAction:(SEL)toggle_report_action { + UITableViewCell* cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil] autorelease]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.backgroundColor = [UIColor clearColor]; + cell.contentView.backgroundColor = [UIColor clearColor]; + + UIView* card = XeniaCompatCardViewForCell(cell); + UIStackView* stack = [[[UIStackView alloc] init] autorelease]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 12.0; + [card addSubview:stack]; + [NSLayoutConstraint activateConstraints:@[ + [stack.topAnchor constraintEqualToAnchor:card.topAnchor constant:16], + [stack.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:16], + [stack.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-16], + [stack.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-16], + ]]; + + UIView* heading_row = [[[UIView alloc] init] autorelease]; + heading_row.translatesAutoresizingMaskIntoConstraints = NO; + heading_row.backgroundColor = [XeniaTheme bgSurface]; + UILabel* heading_label = [[[UILabel alloc] init] autorelease]; + heading_label.translatesAutoresizingMaskIntoConstraints = NO; + heading_label.text = @"Discussion"; + heading_label.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + heading_label.textColor = [XeniaTheme textPrimary]; + [heading_row addSubview:heading_label]; + + UIButton* heading_button = [UIButton buttonWithType:UIButtonTypeSystem]; + heading_button.translatesAutoresizingMaskIntoConstraints = NO; + NSString* button_title = issue_number > 0 + ? [NSString stringWithFormat:@"View Issue #%ld", (long)issue_number] + : @"View on GitHub"; + [heading_button setTitle:button_title forState:UIControlStateNormal]; + [heading_button setTitleColor:[XeniaTheme accent] forState:UIControlStateNormal]; + heading_button.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + heading_button.hidden = (issue_url == nil); + [heading_button addTarget:target + action:view_issue_action + forControlEvents:UIControlEventTouchUpInside]; + [heading_row addSubview:heading_button]; + + [NSLayoutConstraint activateConstraints:@[ + [heading_label.topAnchor constraintEqualToAnchor:heading_row.topAnchor], + [heading_label.leadingAnchor constraintEqualToAnchor:heading_row.leadingAnchor], + [heading_label.bottomAnchor constraintEqualToAnchor:heading_row.bottomAnchor], + [heading_button.firstBaselineAnchor constraintEqualToAnchor:heading_label.firstBaselineAnchor], + [heading_button.trailingAnchor constraintEqualToAnchor:heading_row.trailingAnchor], + [heading_button.leadingAnchor constraintGreaterThanOrEqualToAnchor:heading_label.trailingAnchor + constant:8], + ]]; + [stack addArrangedSubview:heading_row]; + + if (loading) { + UILabel* loading_label = [[[UILabel alloc] init] autorelease]; + loading_label.text = @"Loading discussion..."; + loading_label.font = [UIFont systemFontOfSize:15]; + loading_label.textColor = [XeniaTheme textMuted]; + loading_label.numberOfLines = 1; + [stack addArrangedSubview:loading_label]; + return cell; + } + + if (reports.count == 0) { + UILabel* empty_label = [[[UILabel alloc] init] autorelease]; + empty_label.text = @"No reports yet. Be the first to submit one."; + empty_label.font = [UIFont systemFontOfSize:15]; + empty_label.textColor = [XeniaTheme textMuted]; + empty_label.numberOfLines = 0; + [stack addArrangedSubview:empty_label]; + return cell; + } + + NSInteger report_count = (NSInteger)reports.count; + NSInteger visible_count = show_all ? report_count : MIN(report_count, kXeniaDiscussionPreviewCount); + for (NSInteger report_index = 0; report_index < visible_count; ++report_index) { + NSDictionary* report = reports[report_index]; + if (![report isKindOfClass:[NSDictionary class]]) { + continue; + } + [stack addArrangedSubview:XeniaCompatDiscussionPreviewCardForReport( + report, report_index, expanded_report_indexes, issue_url, target, + view_issue_action, toggle_report_action)]; + } + + if (report_count > kXeniaDiscussionPreviewCount) { + UILabel* summary_label = [[[UILabel alloc] init] autorelease]; + summary_label.text = + show_all + ? [NSString stringWithFormat:@"Showing all %ld reports.", (long)report_count] + : [NSString stringWithFormat:@"Showing latest %ld of %ld reports.", (long)visible_count, + (long)report_count]; + summary_label.font = [UIFont systemFontOfSize:12]; + summary_label.textColor = [XeniaTheme textMuted]; + summary_label.numberOfLines = 1; + [stack addArrangedSubview:summary_label]; + + UIButton* toggle_button = [UIButton buttonWithType:UIButtonTypeSystem]; + toggle_button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + NSString* toggle_title = show_all + ? @"Show fewer reports" + : [NSString stringWithFormat:@"Show all %ld reports", + (long)report_count]; + [toggle_button setTitle:toggle_title forState:UIControlStateNormal]; + [toggle_button setTitleColor:[XeniaTheme accent] forState:UIControlStateNormal]; + toggle_button.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold]; + [toggle_button addTarget:target + action:toggle_expansion_action + forControlEvents:UIControlEventTouchUpInside]; + [stack addArrangedSubview:toggle_button]; + } + + return cell; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h b/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h new file mode 100644 index 000000000..9f1474c38 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h @@ -0,0 +1,42 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_DISCUSSION_CONTROLLER_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_DISCUSSION_CONTROLLER_H_ + +#import + +#include + +@class XeniaGameCompatibilityDiscussionController; + +@protocol XeniaGameCompatibilityDiscussionControllerDelegate +- (void)compatibilityDiscussionControllerDidUpdate: + (XeniaGameCompatibilityDiscussionController*)controller; +@end + +@interface XeniaGameCompatibilityDiscussionController : NSObject +@property(nonatomic, assign) id delegate; +@property(nonatomic, readonly) NSArray* reports; +@property(nonatomic, readonly) NSSet* expandedReportIndexes; +@property(nonatomic, readonly) NSString* issueURL; +@property(nonatomic, readonly) NSInteger issueNumber; +@property(nonatomic, readonly, getter=isLoading) BOOL loading; +@property(nonatomic, readonly) BOOL showAll; + +- (instancetype)initWithTitleID:(uint32_t)title_id compatInfo:(NSDictionary*)compat_info; +- (void)setCompatInfo:(NSDictionary*)compat_info; +- (NSDictionary*)latestReport; +- (void)loadFromCompatibilityData; +- (BOOL)handleDiscussionNotification:(NSNotification*)notification; +- (void)toggleShowAll; +- (void)toggleReportExpandedAtIndex:(NSInteger)report_index; +@end + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_DISCUSSION_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.mm b/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.mm new file mode 100644 index 000000000..b8741a5cd --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.mm @@ -0,0 +1,301 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h" + +#import "xenia/ui/ios/launcher/ios_compat_data.h" + +namespace { + +constexpr NSInteger kXeniaDiscussionPreviewCount = 3; +constexpr NSTimeInterval kXeniaDiscussionCacheFreshSeconds = 300.0; + +} // namespace + +@implementation XeniaGameCompatibilityDiscussionController { + uint32_t title_id_; + NSDictionary* compat_info_; + NSMutableArray* reports_; + NSMutableSet* expanded_report_indexes_; + NSString* issue_url_; + NSInteger issue_number_; + BOOL loading_; + BOOL show_all_; +} + +@synthesize delegate; + +- (instancetype)initWithTitleID:(uint32_t)title_id compatInfo:(NSDictionary*)compat_info { + self = [super init]; + if (self) { + title_id_ = title_id; + compat_info_ = [compat_info retain]; + reports_ = [[NSMutableArray alloc] init]; + expanded_report_indexes_ = [[NSMutableSet alloc] init]; + loading_ = YES; + issue_number_ = 0; + show_all_ = NO; + } + return self; +} + +- (void)dealloc { + [compat_info_ release]; + [reports_ release]; + [expanded_report_indexes_ release]; + [issue_url_ release]; + [super dealloc]; +} + +- (NSArray*)reports { + return reports_; +} + +- (NSSet*)expandedReportIndexes { + return expanded_report_indexes_; +} + +- (NSString*)issueURL { + return issue_url_; +} + +- (NSInteger)issueNumber { + return issue_number_; +} + +- (BOOL)isLoading { + return loading_; +} + +- (BOOL)showAll { + return show_all_; +} + +- (void)setCompatInfo:(NSDictionary*)compat_info { + if (compat_info_ == compat_info) { + return; + } + [compat_info_ release]; + compat_info_ = [compat_info retain]; +} + +- (NSDictionary*)latestReport { + if (reports_.count == 0) { + return nil; + } + id report = reports_.firstObject; + return [report isKindOfClass:[NSDictionary class]] ? report : nil; +} + +- (void)notifyDidUpdate { + [delegate compatibilityDiscussionControllerDidUpdate:self]; +} + +- (NSDictionary*)discussionSnapshotFromCompatibilityData { + if (!compat_info_) { + return nil; + } + + NSMutableDictionary* snapshot = [NSMutableDictionary dictionary]; + id reports = compat_info_[@"reports"]; + if ([reports isKindOfClass:[NSArray class]]) { + snapshot[@"reports"] = reports; + } + + id issue_url = compat_info_[@"issueUrl"]; + if ([issue_url isKindOfClass:[NSString class]] && [issue_url length] > 0) { + snapshot[@"issueUrl"] = issue_url; + } + + id issue_number = compat_info_[@"issueNumber"]; + if ([issue_number isKindOfClass:[NSNumber class]]) { + snapshot[@"issueNumber"] = issue_number; + } + + return snapshot.count > 0 ? snapshot : nil; +} + +- (BOOL)needsDiscussionNetworkFallback { + if (!compat_info_) { + return YES; + } + + NSArray* reports = + [compat_info_[@"reports"] isKindOfClass:[NSArray class]] ? compat_info_[@"reports"] : nil; + BOOL has_reports = reports.count > 0; + BOOL has_issue_url = [compat_info_[@"issueUrl"] isKindOfClass:[NSString class]] && + [compat_info_[@"issueUrl"] length] > 0; + BOOL has_issue_number = [compat_info_[@"issueNumber"] isKindOfClass:[NSNumber class]]; + + return !has_reports || (!has_issue_url && !has_issue_number); +} + +- (void)loadFromCompatibilityData { + NSDictionary* snapshot = [self discussionSnapshotFromCompatibilityData]; + if (snapshot) { + [self applyDiscussionJSON:snapshot]; + loading_ = NO; + [self notifyDidUpdate]; + } + + if ([self needsDiscussionNetworkFallback]) { + loading_ = YES; + [self fetchDiscussion]; + } else if (!snapshot) { + loading_ = NO; + } +} + +- (void)applyDiscussionJSON:(NSDictionary*)json { + [reports_ removeAllObjects]; + [expanded_report_indexes_ removeAllObjects]; + + NSArray* raw_reports = json[@"reports"]; + if ([raw_reports isKindOfClass:[NSArray class]]) { + for (id item in raw_reports) { + if ([item isKindOfClass:[NSDictionary class]]) { + [reports_ addObject:item]; + } + } + } + + [issue_url_ release]; + issue_url_ = nil; + id issue_url = json[@"issueUrl"]; + if ([issue_url isKindOfClass:[NSString class]] && [issue_url length] > 0) { + issue_url_ = [issue_url copy]; + } + + issue_number_ = 0; + id issue_number = json[@"issueNumber"]; + if ([issue_number isKindOfClass:[NSNumber class]]) { + issue_number_ = [issue_number integerValue]; + } + + if (reports_.count <= kXeniaDiscussionPreviewCount) { + show_all_ = NO; + } + if (reports_.count > 0) { + [expanded_report_indexes_ addObject:@0]; + } +} + +- (void)fetchDiscussion { + NSString* cache_path = xe_discussion_cache_path(title_id_); + if (reports_.count == 0) { + NSData* cached_data = [NSData dataWithContentsOfFile:cache_path]; + if (cached_data.length > 0) { + NSError* cache_error = nil; + id cached_json = [NSJSONSerialization JSONObjectWithData:cached_data + options:0 + error:&cache_error]; + if (!cache_error && [cached_json isKindOfClass:[NSDictionary class]]) { + [self applyDiscussionJSON:(NSDictionary*)cached_json]; + loading_ = NO; + [self notifyDidUpdate]; + } + } + + NSDictionary* cache_attributes = + [[NSFileManager defaultManager] attributesOfItemAtPath:cache_path error:nil]; + NSDate* cache_modified_date = cache_attributes[NSFileModificationDate]; + if (cache_modified_date && + [[NSDate date] timeIntervalSinceDate:cache_modified_date] < + kXeniaDiscussionCacheFreshSeconds && + !loading_) { + return; + } + } + + NSString* url_string = [NSString + stringWithFormat:@"https://xenios-compat-api.xenios.workers.dev/games/%08X/discussion", + title_id_]; + NSURL* url = [NSURL URLWithString:url_string]; + if (!url) { + loading_ = NO; + [self notifyDidUpdate]; + return; + } + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithURL:url + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + if (error || data.length == 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + self->loading_ = NO; + [self notifyDidUpdate]; + }); + return; + } + + NSHTTPURLResponse* http_response = (NSHTTPURLResponse*)response; + if (![http_response isKindOfClass:[NSHTTPURLResponse class]] || + http_response.statusCode != 200) { + dispatch_async(dispatch_get_main_queue(), ^{ + self->loading_ = NO; + [self notifyDidUpdate]; + }); + return; + } + + NSError* json_error = nil; + id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&json_error]; + if (json_error || ![json isKindOfClass:[NSDictionary class]]) { + dispatch_async(dispatch_get_main_queue(), ^{ + self->loading_ = NO; + [self notifyDidUpdate]; + }); + return; + } + + [data writeToFile:cache_path atomically:YES]; + dispatch_async(dispatch_get_main_queue(), ^{ + self->loading_ = NO; + [self applyDiscussionJSON:(NSDictionary*)json]; + [self notifyDidUpdate]; + }); + }]; + [task resume]; +} + +- (BOOL)handleDiscussionNotification:(NSNotification*)notification { + NSNumber* updated_title_id = notification.userInfo[@"titleId"]; + if (![updated_title_id isKindOfClass:[NSNumber class]] || + [updated_title_id unsignedIntValue] != title_id_) { + return NO; + } + + NSDictionary* snapshot = xe_dictionary_from_object(notification.userInfo[@"discussion"]); + if (snapshot) { + [self applyDiscussionJSON:snapshot]; + loading_ = NO; + [self notifyDidUpdate]; + return YES; + } + + loading_ = YES; + [self fetchDiscussion]; + return YES; +} + +- (void)toggleShowAll { + show_all_ = !show_all_; + [self notifyDidUpdate]; +} + +- (void)toggleReportExpandedAtIndex:(NSInteger)report_index { + NSNumber* boxed_index = [NSNumber numberWithInteger:report_index]; + if ([expanded_report_indexes_ containsObject:boxed_index]) { + [expanded_report_indexes_ removeObject:boxed_index]; + } else { + [expanded_report_indexes_ addObject:boxed_index]; + } +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h b/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h new file mode 100644 index 000000000..1ff8cdb2d --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h @@ -0,0 +1,42 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_HERO_VIEW_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_HERO_VIEW_H_ + +#import + +#include + +@interface XeniaGameCompatibilityHeroView : NSObject +@property(nonatomic, readonly) UIView* backgroundView; +@property(nonatomic, readonly) UIImage* heroArtwork; +@property(nonatomic, readonly) UIImage* heroBackgroundArtwork; + +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + closeTarget:(id)close_target + closeAction:(SEL)close_action; +- (void)setCompatInfo:(NSDictionary*)compat_info summarySource:(NSDictionary*)summary_source; +- (void)setHeroArtwork:(UIImage*)image; +- (void)setHeroBackgroundArtwork:(UIImage*)image; +- (void)loadArtworkIfNeeded; +- (void)buildIfNeededWithTableView:(UITableView*)table_view controllerView:(UIView*)controller_view; +- (void)layoutInTableView:(UITableView*)table_view + controllerView:(UIView*)controller_view + hostView:(UIView*)host_view; +- (void)layoutOverlayFrames; +- (void)updateGradientFrames; +- (void)ensureTopGlowAnimation; +- (void)updateTraitColors; +- (void)setHidden:(BOOL)hidden; +- (void)hideAndRemoveFromSuperview; +@end + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_GAME_COMPATIBILITY_HERO_VIEW_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.mm b/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.mm new file mode 100644 index 000000000..83e61e568 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_hero_view.mm @@ -0,0 +1,806 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h" + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/launcher/ios_game_art.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +@implementation XeniaGameCompatibilityHeroView { + uint32_t title_id_; + NSString* game_title_; + NSDictionary* compat_info_; + NSDictionary* summary_source_; + UIImage* hero_artwork_; + UIImage* hero_background_artwork_; + CAGradientLayer* hero_background_gradient_layer_; + CAGradientLayer* hero_wave_layer_a_; + CAGradientLayer* hero_wave_layer_b_; + CAGradientLayer* hero_wave_layer_c_; + UIColor* hero_glow_color_; + UIColor* hero_glow_secondary_color_; + UIView* hero_handle_view_; + UILabel* hero_sheet_title_label_; + UIButton* hero_close_button_; + UIStackView* hero_content_stack_; + UIView* hero_header_view_; + UIView* hero_background_view_; + UIView* hero_header_card_view_; + UIImageView* hero_header_backdrop_view_; + UIVisualEffectView* hero_header_blur_view_; + CAGradientLayer* hero_header_scrim_layer_; + UILabel* hero_title_label_; + UILabel* hero_tid_label_; + UIStackView* hero_pills_stack_; + XeniaPaddedLabel* hero_status_pill_; + XeniaPaddedLabel* hero_perf_pill_; + UILabel* hero_updated_label_; + id close_target_; + SEL close_action_; + BOOL hero_scroll_layout_initialized_; +} + +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + closeTarget:(id)close_target + closeAction:(SEL)close_action { + self = [super init]; + if (self) { + title_id_ = title_id; + game_title_ = [title copy]; + close_target_ = close_target; + close_action_ = close_action; + hero_glow_color_ = [[XeniaTheme accent] retain]; + hero_glow_secondary_color_ = [[XeniaTheme accentHover] retain]; + } + return self; +} + +- (void)dealloc { + [self hideAndRemoveFromSuperview]; + [game_title_ release]; + [compat_info_ release]; + [summary_source_ release]; + [hero_artwork_ release]; + [hero_background_artwork_ release]; + [hero_background_gradient_layer_ release]; + [hero_wave_layer_a_ release]; + [hero_wave_layer_b_ release]; + [hero_wave_layer_c_ release]; + [hero_glow_color_ release]; + [hero_glow_secondary_color_ release]; + [hero_handle_view_ release]; + [hero_sheet_title_label_ release]; + [hero_close_button_ release]; + [hero_content_stack_ release]; + [hero_header_view_ release]; + [hero_background_view_ release]; + [hero_header_card_view_ release]; + [hero_header_backdrop_view_ release]; + [hero_header_blur_view_ release]; + [hero_header_scrim_layer_ release]; + [hero_title_label_ release]; + [hero_tid_label_ release]; + [hero_pills_stack_ release]; + [hero_status_pill_ release]; + [hero_perf_pill_ release]; + [hero_updated_label_ release]; + [super dealloc]; +} + +- (UIView*)backgroundView { + return hero_background_view_; +} + +- (UIImage*)heroArtwork { + return hero_artwork_; +} + +- (UIImage*)heroBackgroundArtwork { + return hero_background_artwork_; +} + +- (void)setHeroArtwork:(UIImage*)image { + if (hero_artwork_ == image) { + return; + } + [hero_artwork_ release]; + hero_artwork_ = [image retain]; + if (!hero_background_artwork_) { + [self updateHeroGlowColorFromImage:image]; + } + [self updateHeroHeaderArtwork]; +} + +- (void)setHeroBackgroundArtwork:(UIImage*)image { + if (hero_background_artwork_ == image) { + return; + } + [hero_background_artwork_ release]; + hero_background_artwork_ = [image retain]; + [self updateHeroGlowColorFromImage:image]; + [self updateHeroHeaderArtwork]; +} + +- (void)setCompatInfo:(NSDictionary*)compat_info summarySource:(NSDictionary*)summary_source { + if (compat_info_ != compat_info) { + [compat_info_ release]; + compat_info_ = [compat_info retain]; + } + if (summary_source_ != summary_source) { + [summary_source_ release]; + summary_source_ = [summary_source retain]; + } + [self updateHeroHeaderContent]; +} + +- (NSDictionary*)bestResultSource { + return summary_source_ ?: compat_info_; +} + +- (void)updateHeroHeaderArtwork { + if (!hero_header_backdrop_view_) { + return; + } + UIImage* display = hero_background_artwork_ ?: hero_artwork_; + hero_header_backdrop_view_.image = display; + hero_header_backdrop_view_.hidden = (display == nil); + hero_header_backdrop_view_.layer.contentsRect = + hero_background_artwork_ ? CGRectMake(0.0, 0.0, 1.0, 1.0) : CGRectMake(0.0, 0.18, 1.0, 0.82); + hero_header_card_view_.backgroundColor = + display ? [XeniaTheme bgSurface] : [XeniaTheme bgSurface2]; + [self applyHeroGlowColors]; +} + +- (void)updateHeroGlowColorFromImage:(UIImage*)image { + if (!image) { + return; + } + XEHeroGlowPalette palette = xe_extract_hero_glow_palette(image); + UIColor* primary = palette.primary ?: [XeniaTheme accent]; + UIColor* secondary = palette.secondary ?: [XeniaTheme accentHover]; + [hero_glow_color_ release]; + hero_glow_color_ = [primary retain]; + [hero_glow_secondary_color_ release]; + hero_glow_secondary_color_ = [secondary retain]; + [self applyHeroGlowColors]; +} + +- (void)applyHeroGlowColors { + if (!hero_background_gradient_layer_) { + return; + } + + UIColor* primary = hero_glow_color_ ?: [XeniaTheme accent]; + UIColor* secondary = + hero_glow_secondary_color_ ?: xe_blend_rgb_colors(primary, [XeniaTheme accentHover], 0.26); + UIColor* tertiary = xe_blend_rgb_colors(primary, secondary, 0.34); + CGFloat avg_luma = xe_color_luma(primary) * 0.70 + xe_color_luma(secondary) * 0.30; + BOOL has_background_art = (hero_background_artwork_ != nil); + if (hero_header_backdrop_view_) { + if (has_background_art) { + hero_header_backdrop_view_.alpha = MIN(0.96, MAX(0.78, 0.94 - avg_luma * 0.20)); + } else { + hero_header_backdrop_view_.alpha = MIN(0.88, MAX(0.62, 0.82 - avg_luma * 0.24)); + } + } + if (hero_header_blur_view_) { + if (has_background_art) { + hero_header_blur_view_.alpha = MIN(0.36, MAX(0.14, 0.14 + avg_luma * 0.14)); + } else { + hero_header_blur_view_.alpha = MIN(0.60, MAX(0.30, 0.36 + avg_luma * 0.18)); + } + } + if (hero_header_scrim_layer_) { + CGFloat top_alpha = MIN(0.30, MAX(0.10, 0.10 + avg_luma * 0.16)); + CGFloat mid_alpha = MIN(0.60, MAX(0.38, 0.38 + avg_luma * 0.20)); + CGFloat bottom_alpha = MIN(0.92, MAX(0.80, 0.80 + avg_luma * 0.12)); + hero_header_scrim_layer_.colors = @[ + (id)[UIColor colorWithWhite:0.0 alpha:top_alpha].CGColor, + (id)[UIColor colorWithWhite:0.0 alpha:mid_alpha].CGColor, + (id)[UIColor colorWithWhite:0.0 alpha:bottom_alpha].CGColor, + ]; + hero_header_scrim_layer_.locations = @[ @0.0, @0.58, @1.0 ]; + } + + hero_background_gradient_layer_.colors = @[ + (id)[primary colorWithAlphaComponent:0.28].CGColor, + (id)[secondary colorWithAlphaComponent:0.16].CGColor, + (id)[tertiary colorWithAlphaComponent:0.08].CGColor, + (id)[UIColor clearColor].CGColor, + ]; + hero_background_gradient_layer_.locations = @[ @0.0, @0.30, @0.66, @1.0 ]; + + if (hero_wave_layer_a_) { + hero_wave_layer_a_.colors = @[ + (id)[primary colorWithAlphaComponent:0.24].CGColor, + (id)[primary colorWithAlphaComponent:0.14].CGColor, + (id)[primary colorWithAlphaComponent:0.06].CGColor, + (id)[UIColor clearColor].CGColor, + ]; + } + if (hero_wave_layer_b_) { + hero_wave_layer_b_.colors = @[ + (id)[secondary colorWithAlphaComponent:0.21].CGColor, + (id)[secondary colorWithAlphaComponent:0.12].CGColor, + (id)[secondary colorWithAlphaComponent:0.05].CGColor, + (id)[UIColor clearColor].CGColor, + ]; + } + if (hero_wave_layer_c_) { + UIColor* cloud = xe_blend_rgb_colors(secondary, primary, 0.24); + hero_wave_layer_c_.colors = @[ + (id)[cloud colorWithAlphaComponent:0.18].CGColor, + (id)[cloud colorWithAlphaComponent:0.10].CGColor, + (id)[cloud colorWithAlphaComponent:0.04].CGColor, + (id)[UIColor clearColor].CGColor, + ]; + } +} + +- (void)layoutOverlayFrames { + if (!hero_header_card_view_) { + return; + } + CGRect bounds = hero_header_card_view_.bounds; + if (CGRectIsEmpty(bounds)) { + return; + } + CGFloat card_w = bounds.size.width; + + // Handle: centered, 60x6, 8pt from top. + CGFloat handle_w = 60.0, handle_h = 6.0; + hero_handle_view_.frame = CGRectMake(floor((card_w - handle_w) / 2.0), 8.0, handle_w, handle_h); + + // Sheet title: centered, below handle. + CGFloat title_y = CGRectGetMaxY(hero_handle_view_.frame) + 16.0; + CGFloat title_max_w = card_w - 72.0 - 60.0; // left margin + close button area + CGSize title_size = [hero_sheet_title_label_ sizeThatFits:CGSizeMake(title_max_w, CGFLOAT_MAX)]; + hero_sheet_title_label_.frame = CGRectMake(floor((card_w - title_size.width) / 2.0), title_y, + ceil(title_size.width), ceil(title_size.height)); + + // Close button: 48x48, right-aligned, vertically centered with title. + CGFloat btn_size = 48.0; + CGFloat title_center_y = CGRectGetMidY(hero_sheet_title_label_.frame); + hero_close_button_.frame = CGRectMake(card_w - 16.0 - btn_size, + floor(title_center_y - btn_size / 2.0), btn_size, btn_size); +} + +- (void)updateGradientFrames { + if (!hero_header_card_view_) { + return; + } + CGRect bounds = hero_header_card_view_.bounds; + if (CGRectIsEmpty(bounds)) { + return; + } + hero_header_scrim_layer_.frame = bounds; + hero_background_gradient_layer_.frame = bounds; + CGRect wave_frame = CGRectInset(bounds, -bounds.size.width * 0.44, -bounds.size.height * 0.86); + wave_frame.origin.y -= bounds.size.height * 0.60; + hero_wave_layer_a_.frame = wave_frame; + hero_wave_layer_b_.frame = wave_frame; + hero_wave_layer_c_.frame = wave_frame; +} + +- (void)ensureTopGlowAnimation { + if (!hero_wave_layer_a_ || !hero_wave_layer_b_ || !hero_wave_layer_c_ || + UIAccessibilityIsReduceMotionEnabled()) { + [hero_wave_layer_a_ removeAllAnimations]; + [hero_wave_layer_b_ removeAllAnimations]; + [hero_wave_layer_c_ removeAllAnimations]; + hero_wave_layer_a_.opacity = 0.08; + hero_wave_layer_b_.opacity = 0.06; + hero_wave_layer_c_.opacity = 0.05; + return; + } + + NSArray* waves = + @[ hero_wave_layer_a_, hero_wave_layer_b_, hero_wave_layer_c_ ]; + NSArray* durations = @[ @19.8, @24.2, @28.4 ]; + NSArray* peaks = @[ @0.13, @0.10, @0.08 ]; + NSArray* scale_y_to = @[ @1.08, @1.10, @1.07 ]; + NSArray* scale_x_to = @[ @1.06, @1.08, @1.05 ]; + + for (NSUInteger i = 0; i < waves.count; ++i) { + CAGradientLayer* wave = waves[i]; + NSString* key = [NSString stringWithFormat:@"xenia.hero.wave.%lu.group", (unsigned long)i]; + if ([wave animationForKey:key]) { + continue; + } + + double peak = [peaks[i] doubleValue]; + CAKeyframeAnimation* opacity = [CAKeyframeAnimation animationWithKeyPath:@"opacity"]; + opacity.values = + @[ @0.02, @(peak * 0.70), @(peak * 0.95), @(peak * 0.50), @0.03, @(peak * 0.82), @0.02 ]; + opacity.keyTimes = @[ @0.0, @0.15, @0.33, @0.51, @0.68, @0.86, @1.0 ]; + opacity.calculationMode = kCAAnimationLinear; + + CAKeyframeAnimation* scale_y = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale.y"]; + scale_y.values = @[ @0.97, @1.01, scale_y_to[i], @1.02, @0.99, @1.03, @0.97 ]; + scale_y.keyTimes = @[ @0.0, @0.18, @0.36, @0.55, @0.71, @0.88, @1.0 ]; + scale_y.calculationMode = kCAAnimationLinear; + + CAKeyframeAnimation* scale_x = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale.x"]; + scale_x.values = @[ @0.98, @1.01, scale_x_to[i], @1.01, @0.99, @1.02, @0.98 ]; + scale_x.keyTimes = @[ @0.0, @0.16, @0.34, @0.52, @0.69, @0.87, @1.0 ]; + scale_x.calculationMode = kCAAnimationLinear; + + CGFloat x_span = wave.bounds.size.width * (0.004 + 0.002 * (CGFloat)i); + CGFloat y_span = wave.bounds.size.height * (0.003 + 0.002 * (CGFloat)i); + CGFloat x_jitter = (((CGFloat)arc4random_uniform(180) / 100.0) - 0.90) * x_span; + CGFloat y_jitter = (((CGFloat)arc4random_uniform(180) / 100.0) - 0.90) * y_span; + CAKeyframeAnimation* drift_x = + [CAKeyframeAnimation animationWithKeyPath:@"transform.translation.x"]; + drift_x.values = @[ + @(-x_span * 0.26), @(x_span * 0.18 + x_jitter), @(x_span * 0.34), @(x_span * -0.08), + @(-x_span * 0.22) + ]; + drift_x.keyTimes = @[ @0.0, @0.27, @0.52, @0.78, @1.0 ]; + drift_x.calculationMode = kCAAnimationLinear; + CAKeyframeAnimation* drift_y = + [CAKeyframeAnimation animationWithKeyPath:@"transform.translation.y"]; + drift_y.values = @[ + @(-y_span * 0.22), @(y_span * 0.20), @(y_span * 0.34 + y_jitter), @(y_span * -0.04), + @(-y_span * 0.22) + ]; + drift_y.keyTimes = @[ @0.0, @0.29, @0.58, @0.81, @1.0 ]; + drift_y.calculationMode = kCAAnimationLinear; + + CAAnimationGroup* group = [CAAnimationGroup animation]; + group.animations = @[ opacity, scale_y, scale_x, drift_x, drift_y ]; + group.duration = [durations[i] doubleValue] + ((double)arc4random_uniform(520) / 100.0); + group.beginTime = CACurrentMediaTime() + ((double)arc4random_uniform(420) / 100.0); + group.repeatCount = HUGE_VALF; + group.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]; + group.removedOnCompletion = NO; + [wave addAnimation:group forKey:key]; + } +} + +- (void)updateHeroHeaderContent { + if (!hero_header_view_) { + return; + } + + hero_title_label_.text = game_title_.length > 0 ? game_title_ : @"Unknown Title"; + hero_tid_label_.text = title_id_ ? [NSString stringWithFormat:@"Title ID: %08X", title_id_] + : @"No title ID available"; + + NSDictionary* summary_source = [self bestResultSource]; + NSString* status = xe_string_from_object(summary_source[@"status"]); + if (status.length > 0) { + UIColor* status_color = xe_compat_status_color(status); + hero_status_pill_.text = xe_compat_status_label(status); + hero_status_pill_.textColor = status_color; + hero_status_pill_.backgroundColor = [status_color colorWithAlphaComponent:0.1]; + hero_status_pill_.hidden = NO; + } else { + hero_status_pill_.hidden = YES; + } + + NSString* perf = xe_string_from_object(summary_source[@"perf"]); + if (perf.length > 0) { + UIColor* perf_color = xe_compat_perf_color(perf); + hero_perf_pill_.text = xe_compat_perf_label(perf); + hero_perf_pill_.textColor = perf_color; + hero_perf_pill_.backgroundColor = [perf_color colorWithAlphaComponent:0.1]; + hero_perf_pill_.hidden = NO; + } else { + hero_perf_pill_.hidden = YES; + } + hero_pills_stack_.hidden = hero_status_pill_.hidden && hero_perf_pill_.hidden; + + NSString* updated_at = + [compat_info_[@"updatedAt"] isKindOfClass:[NSString class]] + ? compat_info_[@"updatedAt"] + : ([summary_source[@"date"] isKindOfClass:[NSString class]] ? summary_source[@"date"] + : nil); + if (updated_at.length > 0) { + hero_updated_label_.text = + [NSString stringWithFormat:@"Last updated: %@", xe_format_iso_date(updated_at)]; + hero_updated_label_.hidden = NO; + } else { + hero_updated_label_.hidden = YES; + } + + [self updateHeroHeaderArtwork]; +} + +- (void)layoutInTableView:(UITableView*)table_view + controllerView:(UIView*)controller_view + hostView:(UIView*)host_view { + if (!hero_header_view_) { + return; + } + CGFloat width = CGRectGetWidth(table_view.bounds); + if (width <= 0.0) { + width = CGRectGetWidth(controller_view.bounds); + } + if (width <= 0.0) { + return; + } + + CGFloat height = xe_compat_hero_height_for_width(width, hero_background_artwork_); + if (!hero_background_view_) { + hero_background_view_ = [[UIView alloc] initWithFrame:CGRectZero]; + hero_background_view_.backgroundColor = [XeniaTheme bgPrimary]; + hero_background_view_.clipsToBounds = NO; + [hero_background_view_ addSubview:hero_header_view_]; + } + host_view = host_view ?: controller_view.superview ?: controller_view; + CGFloat safe_top = 0.0; + if (@available(iOS 11.0, *)) { + UIWindow* window = host_view.window ?: controller_view.window; + safe_top = window ? window.safeAreaInsets.top : host_view.safeAreaInsets.top; + } + if (host_view && hero_background_view_.superview != host_view) { + [hero_background_view_ removeFromSuperview]; + [host_view addSubview:hero_background_view_]; + } + CGRect table_frame = host_view ? [controller_view convertRect:controller_view.bounds + toView:host_view] + : controller_view.bounds; + CGFloat hero_top = CGRectGetMinY(table_frame); + hero_background_view_.frame = + CGRectMake(CGRectGetMinX(table_frame), hero_top, width, height + safe_top); + hero_header_view_.frame = CGRectMake(0.0, safe_top, width, height); + CGFloat desired_top_inset = + CGRectGetMaxY(hero_background_view_.frame) - CGRectGetMinY(table_frame) + 12.0; + CGFloat relative_offset = table_view.contentOffset.y + table_view.contentInset.top; + if (fabs(table_view.contentInset.top - desired_top_inset) > 0.5) { + UIEdgeInsets content_inset = table_view.contentInset; + content_inset.top = desired_top_inset; + table_view.contentInset = content_inset; + if (@available(iOS 13.0, *)) { + UIEdgeInsets vertical_insets = table_view.verticalScrollIndicatorInsets; + vertical_insets.top = desired_top_inset; + table_view.verticalScrollIndicatorInsets = vertical_insets; + } else { + UIEdgeInsets indicator_insets = content_inset; + indicator_insets.top = desired_top_inset; + table_view.scrollIndicatorInsets = indicator_insets; + } + table_view.contentOffset = CGPointMake(table_view.contentOffset.x, + relative_offset - desired_top_inset); + } + if (!hero_scroll_layout_initialized_) { + table_view.contentOffset = CGPointMake(table_view.contentOffset.x, -desired_top_inset); + hero_scroll_layout_initialized_ = YES; + } + [hero_header_view_ setNeedsLayout]; + [hero_header_view_ layoutIfNeeded]; + hero_header_scrim_layer_.frame = hero_header_card_view_.bounds; + [self updateHeroHeaderArtwork]; +} + +- (void)buildIfNeededWithTableView:(UITableView*)table_view + controllerView:(UIView*)controller_view { + if (hero_header_view_) { + [self updateHeroHeaderContent]; + [self layoutInTableView:table_view controllerView:controller_view hostView:nil]; + return; + } + + CGFloat width = CGRectGetWidth(table_view.bounds); + if (width <= 0.0) { + width = CGRectGetWidth(controller_view.bounds); + } + if (width <= 0.0) { + width = UIScreen.mainScreen.bounds.size.width; + } + + CGFloat height = xe_compat_hero_height_for_width(width, hero_background_artwork_); + hero_header_view_ = [[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, width, height)]; + hero_header_view_.backgroundColor = [UIColor clearColor]; + // The hero card composites text on top of the game cover artwork (always + // a dark image for Xbox 360 box art). Force-dark on this subtree so + // textPrimary / textSecondary and the blur style stay light-on-dark + // regardless of the system mode — same approach as the in-game HUD, + // which is also rendered over a darker canvas. + hero_header_view_.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; + hero_background_view_ = [[UIView alloc] initWithFrame:hero_header_view_.frame]; + hero_background_view_.backgroundColor = [XeniaTheme bgPrimary]; + hero_background_view_.clipsToBounds = NO; + [hero_background_view_ addSubview:hero_header_view_]; + + hero_header_card_view_ = [[UIView alloc] init]; + hero_header_card_view_.translatesAutoresizingMaskIntoConstraints = NO; + hero_header_card_view_.backgroundColor = [XeniaTheme bgSurface]; + // Intentionally larger than the design system's XeniaRadiusXxl (24pt). The + // hero card is the largest sheet surface and reads as "iPad-class chrome" + // at 28pt; smaller values make it feel cramped against the artwork edge. + hero_header_card_view_.layer.cornerRadius = 28.0; + if (@available(iOS 11.0, *)) { + hero_header_card_view_.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMaxXMinYCorner; + } + hero_header_card_view_.layer.borderWidth = 0.5; + hero_header_card_view_.layer.borderColor = [XeniaTheme border].CGColor; + hero_header_card_view_.clipsToBounds = YES; + [hero_header_view_ addSubview:hero_header_card_view_]; + + hero_header_backdrop_view_ = [[UIImageView alloc] init]; + hero_header_backdrop_view_.translatesAutoresizingMaskIntoConstraints = NO; + hero_header_backdrop_view_.contentMode = UIViewContentModeScaleAspectFill; + hero_header_backdrop_view_.clipsToBounds = YES; + [hero_header_card_view_ addSubview:hero_header_backdrop_view_]; + + // Clear-variant liquid glass on iOS 26+ (UIGlassEffectStyleClear, per + // Apple HIG for chrome sitting over visually rich content like the game + // artwork backdrop). On iOS 18-25 the helper falls back to + // UIBlurEffectStyleSystemUltraThinMaterial — the same backdrop this view + // used before the helper existed, so pre-iOS-26 rendering is unchanged. + // The parent's force-dark override resolves the blur to a dark variant + // even when the rest of the app is light. + hero_header_blur_view_ = [[UIVisualEffectView alloc] + initWithEffect:xe_make_chrome_visual_effect(YES)]; + hero_header_blur_view_.translatesAutoresizingMaskIntoConstraints = NO; + [hero_header_card_view_ addSubview:hero_header_blur_view_]; + + hero_header_scrim_layer_ = [[CAGradientLayer layer] retain]; + [hero_header_card_view_.layer addSublayer:hero_header_scrim_layer_]; + + // Base glow gradient from the top. + hero_background_gradient_layer_ = [[CAGradientLayer layer] retain]; + [hero_header_card_view_.layer insertSublayer:hero_background_gradient_layer_ + above:hero_header_scrim_layer_]; + + // Three layered radial-glow waves. + hero_wave_layer_a_ = [[CAGradientLayer layer] retain]; + hero_wave_layer_a_.type = kCAGradientLayerRadial; + hero_wave_layer_a_.locations = @[ @0.00, @0.34, @0.68, @1.00 ]; + hero_wave_layer_a_.startPoint = CGPointMake(0.52, -0.44); + hero_wave_layer_a_.endPoint = CGPointMake(0.52, 1.00); + hero_wave_layer_a_.opacity = 0.06; + + hero_wave_layer_b_ = [[CAGradientLayer layer] retain]; + hero_wave_layer_b_.type = kCAGradientLayerRadial; + hero_wave_layer_b_.locations = @[ @0.00, @0.36, @0.70, @1.00 ]; + hero_wave_layer_b_.startPoint = CGPointMake(0.34, -0.52); + hero_wave_layer_b_.endPoint = CGPointMake(0.36, 1.00); + hero_wave_layer_b_.opacity = 0.05; + + hero_wave_layer_c_ = [[CAGradientLayer layer] retain]; + hero_wave_layer_c_.type = kCAGradientLayerRadial; + hero_wave_layer_c_.locations = @[ @0.00, @0.40, @0.72, @1.00 ]; + hero_wave_layer_c_.startPoint = CGPointMake(0.70, -0.50); + hero_wave_layer_c_.endPoint = CGPointMake(0.68, 1.00); + hero_wave_layer_c_.opacity = 0.04; + + [hero_header_card_view_.layer insertSublayer:hero_wave_layer_a_ + above:hero_background_gradient_layer_]; + [hero_header_card_view_.layer insertSublayer:hero_wave_layer_b_ above:hero_wave_layer_a_]; + [hero_header_card_view_.layer insertSublayer:hero_wave_layer_c_ above:hero_wave_layer_b_]; + + [self applyHeroGlowColors]; + + // Handle, sheet title, and close button use manual frames (not auto + // layout) so they survive hero_background_view_ remove/re-add cycles + // across navigation push/pop transitions without constraint breakage. + hero_handle_view_ = [[UIView alloc] init]; + // Dynamic so the handle reads as a light bar in dark mode and a dark bar in + // light mode. Plain whiteColor would vanish on white surfaces. + hero_handle_view_.backgroundColor = + [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? [[UIColor blackColor] colorWithAlphaComponent:0.34] + : [[UIColor whiteColor] colorWithAlphaComponent:0.34]; + }]; + hero_handle_view_.layer.cornerRadius = 3.0; // handle_h / 2 — capsule grabber. + [hero_header_card_view_ addSubview:hero_handle_view_]; + + hero_sheet_title_label_ = [[UILabel alloc] init]; + hero_sheet_title_label_.text = @"Compatibility"; + hero_sheet_title_label_.textColor = [XeniaTheme textPrimary]; + hero_sheet_title_label_.textAlignment = NSTextAlignmentCenter; + xe_apply_label_font(hero_sheet_title_label_, UIFontTextStyleTitle2, 18.0, UIFontWeightSemibold); + [hero_header_card_view_ addSubview:hero_sheet_title_label_]; + + hero_close_button_ = [xe_make_ios_sheet_close_button(close_target_, close_action_) retain]; + // Convert close button from auto layout to manual frame positioning. + NSArray* close_button_constraints = + [[hero_close_button_.constraints copy] autorelease]; + for (NSLayoutConstraint* c in close_button_constraints) { + c.active = NO; + } + hero_close_button_.translatesAutoresizingMaskIntoConstraints = YES; + [hero_header_card_view_ addSubview:hero_close_button_]; + + hero_content_stack_ = [[UIStackView alloc] init]; + hero_content_stack_.translatesAutoresizingMaskIntoConstraints = NO; + hero_content_stack_.axis = UILayoutConstraintAxisVertical; + hero_content_stack_.spacing = 8.0; + [hero_header_card_view_ addSubview:hero_content_stack_]; + + hero_title_label_ = [[UILabel alloc] init]; + hero_title_label_.translatesAutoresizingMaskIntoConstraints = NO; + hero_title_label_.textColor = [XeniaTheme textPrimary]; + hero_title_label_.numberOfLines = 2; + hero_title_label_.lineBreakMode = NSLineBreakByTruncatingTail; + xe_apply_label_font(hero_title_label_, UIFontTextStyleLargeTitle, 26.0, UIFontWeightBold); + [hero_content_stack_ addArrangedSubview:hero_title_label_]; + + hero_tid_label_ = [[UILabel alloc] init]; + hero_tid_label_.translatesAutoresizingMaskIntoConstraints = NO; + hero_tid_label_.textColor = [XeniaTheme textSecondary]; + xe_apply_monospaced_label_font(hero_tid_label_, UIFontTextStyleBody, 13.0, UIFontWeightRegular); + [hero_content_stack_ addArrangedSubview:hero_tid_label_]; + + hero_pills_stack_ = [[UIStackView alloc] init]; + hero_pills_stack_.translatesAutoresizingMaskIntoConstraints = NO; + hero_pills_stack_.axis = UILayoutConstraintAxisHorizontal; + hero_pills_stack_.spacing = 10.0; + hero_pills_stack_.alignment = UIStackViewAlignmentCenter; + [hero_pills_stack_ setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [hero_pills_stack_ setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + + hero_status_pill_ = [[XeniaPaddedLabel alloc] init]; + hero_status_pill_.translatesAutoresizingMaskIntoConstraints = NO; + hero_status_pill_.padding = UIEdgeInsetsMake(2, 7, 2, 7); + hero_status_pill_.textAlignment = NSTextAlignmentCenter; + hero_status_pill_.layer.cornerRadius = XeniaRadiusMd; + hero_status_pill_.clipsToBounds = YES; + xe_apply_label_font(hero_status_pill_, UIFontTextStyleCaption1, 11.0, UIFontWeightMedium); + [hero_status_pill_ setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [hero_status_pill_ setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + hero_status_pill_.hidden = YES; + + hero_perf_pill_ = [[XeniaPaddedLabel alloc] init]; + hero_perf_pill_.translatesAutoresizingMaskIntoConstraints = NO; + hero_perf_pill_.padding = UIEdgeInsetsMake(2, 7, 2, 7); + hero_perf_pill_.textAlignment = NSTextAlignmentCenter; + hero_perf_pill_.layer.cornerRadius = XeniaRadiusMd; + hero_perf_pill_.clipsToBounds = YES; + xe_apply_label_font(hero_perf_pill_, UIFontTextStyleCaption1, 11.0, UIFontWeightMedium); + [hero_perf_pill_ setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [hero_perf_pill_ setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + hero_perf_pill_.hidden = YES; + + [hero_pills_stack_ addArrangedSubview:hero_status_pill_]; + [hero_pills_stack_ addArrangedSubview:hero_perf_pill_]; + + UIView* pills_row = [[[UIView alloc] init] autorelease]; + pills_row.translatesAutoresizingMaskIntoConstraints = NO; + [pills_row addSubview:hero_pills_stack_]; + [NSLayoutConstraint activateConstraints:@[ + [hero_pills_stack_.topAnchor constraintEqualToAnchor:pills_row.topAnchor], + [hero_pills_stack_.leadingAnchor constraintEqualToAnchor:pills_row.leadingAnchor], + [hero_pills_stack_.bottomAnchor constraintEqualToAnchor:pills_row.bottomAnchor], + [hero_pills_stack_.trailingAnchor constraintLessThanOrEqualToAnchor:pills_row.trailingAnchor], + ]]; + [hero_content_stack_ addArrangedSubview:pills_row]; + + hero_updated_label_ = [[UILabel alloc] init]; + hero_updated_label_.translatesAutoresizingMaskIntoConstraints = NO; + hero_updated_label_.textColor = [XeniaTheme textMuted]; + hero_updated_label_.numberOfLines = 1; + xe_apply_label_font(hero_updated_label_, UIFontTextStyleFootnote, 12.0, UIFontWeightRegular); + [hero_content_stack_ addArrangedSubview:hero_updated_label_]; + + [NSLayoutConstraint activateConstraints:@[ + [hero_header_card_view_.topAnchor constraintEqualToAnchor:hero_header_view_.topAnchor], + [hero_header_card_view_.leadingAnchor constraintEqualToAnchor:hero_header_view_.leadingAnchor], + [hero_header_card_view_.trailingAnchor + constraintEqualToAnchor:hero_header_view_.trailingAnchor], + [hero_header_card_view_.bottomAnchor constraintEqualToAnchor:hero_header_view_.bottomAnchor], + [hero_header_backdrop_view_.topAnchor constraintEqualToAnchor:hero_header_card_view_.topAnchor], + [hero_header_backdrop_view_.leadingAnchor + constraintEqualToAnchor:hero_header_card_view_.leadingAnchor], + [hero_header_backdrop_view_.trailingAnchor + constraintEqualToAnchor:hero_header_card_view_.trailingAnchor], + [hero_header_backdrop_view_.bottomAnchor + constraintEqualToAnchor:hero_header_card_view_.bottomAnchor], + [hero_header_blur_view_.topAnchor constraintEqualToAnchor:hero_header_card_view_.topAnchor], + [hero_header_blur_view_.leadingAnchor + constraintEqualToAnchor:hero_header_card_view_.leadingAnchor], + [hero_header_blur_view_.trailingAnchor + constraintEqualToAnchor:hero_header_card_view_.trailingAnchor], + [hero_header_blur_view_.bottomAnchor + constraintEqualToAnchor:hero_header_card_view_.bottomAnchor], + // content_stack uses auto layout only for leading/trailing/bottom. + // No top constraint — its top position is determined by its content height + // and the bottom anchor, avoiding any conflict with the manual-frame views above. + [hero_content_stack_.leadingAnchor constraintEqualToAnchor:hero_header_card_view_.leadingAnchor + constant:28.0], + [hero_content_stack_.trailingAnchor + constraintEqualToAnchor:hero_header_card_view_.trailingAnchor + constant:-28.0], + [hero_content_stack_.bottomAnchor constraintEqualToAnchor:hero_header_card_view_.bottomAnchor + constant:-26.0], + ]]; + + [hero_header_view_ setNeedsLayout]; + [hero_header_view_ layoutIfNeeded]; + [self layoutOverlayFrames]; + [self updateHeroHeaderContent]; + [self layoutInTableView:table_view controllerView:controller_view hostView:nil]; +} + +- (void)loadArtworkIfNeeded { + if (!title_id_) { + return; + } + + UIImage* cached_background = xe_cached_game_background_art(title_id_); + if (cached_background) { + [self setHeroBackgroundArtwork:cached_background]; + [self updateHeroGlowColorFromImage:cached_background]; + } + + if (!hero_artwork_) { + UIImage* cached_cover = xe_cached_game_art(title_id_); + if (cached_cover) { + [self setHeroArtwork:cached_cover]; + } + } + + // If we have artwork but no background, use cover art for the glow. + if (!cached_background && hero_artwork_) { + [self updateHeroGlowColorFromImage:hero_artwork_]; + } + + if ((cached_background || hero_artwork_) && hero_header_view_) { + [self updateHeroHeaderContent]; + } + + const uint32_t expected_title_id = title_id_; + if (!cached_background) { + xe_fetch_game_background_art(expected_title_id, ^(UIImage* image) { + if (!image || self->title_id_ != expected_title_id) { + return; + } + [self setHeroBackgroundArtwork:image]; + [self updateHeroGlowColorFromImage:image]; + if (self->hero_header_view_) { + [self updateHeroHeaderContent]; + } + }); + } + + if (!hero_artwork_) { + xe_fetch_game_art(expected_title_id, ^(UIImage* image) { + if (!image || self->title_id_ != expected_title_id) { + return; + } + [self setHeroArtwork:image]; + if (!self->hero_background_artwork_) { + [self updateHeroGlowColorFromImage:image]; + } + if (self->hero_header_view_) { + [self updateHeroHeaderContent]; + } + }); + } +} + + +- (void)updateTraitColors { + if (hero_header_card_view_) { + hero_header_card_view_.layer.borderColor = [XeniaTheme border].CGColor; + } +} + +- (void)setHidden:(BOOL)hidden { + hero_background_view_.hidden = hidden; +} + +- (void)hideAndRemoveFromSuperview { + hero_background_view_.hidden = YES; + [hero_background_view_ removeFromSuperview]; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h new file mode 100644 index 000000000..5fd75950c --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h @@ -0,0 +1,30 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_COMPATIBILITY_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_GAME_COMPATIBILITY_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Per-game compatibility hero sheet. Renders the cover/background hero card +// with status / perf pills, build pill and the most recent app-submitted +// reports + GitHub discussion summary, plus animated radial-glow accents +// keyed off the cover artwork's palette. +@interface XeniaGameCompatibilityViewController : XESheetTableViewController +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + compatData:(NSDictionary*)compat_data; +- (void)setHeroArtwork:(UIImage*)image; +@end + +#endif // XENIA_UI_IOS_GAME_COMPATIBILITY_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.mm new file mode 100644 index 000000000..caea761a8 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_compatibility_view_controller.mm @@ -0,0 +1,317 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_compatibility_view_controller.h" + +#include + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/launcher/ios_compat_report_view_controller.h" +#import "xenia/ui/ios/launcher/ios_game_compatibility_cells.h" +#import "xenia/ui/ios/launcher/ios_game_compatibility_discussion_controller.h" +#import "xenia/ui/ios/launcher/ios_game_compatibility_hero_view.h" +#import "xenia/ui/ios/app/ios_landscape_navigation_controller.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@interface XeniaGameCompatibilityViewController () < + XeniaGameCompatibilityDiscussionControllerDelegate> +@end + +@implementation XeniaGameCompatibilityViewController { + uint32_t title_id_; + NSString* game_title_; + NSDictionary* compat_info_; + XeniaGameCompatibilityHeroView* hero_view_; + XeniaGameCompatibilityDiscussionController* discussion_controller_; +} + +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + compatData:(NSDictionary*)compat_data { + self = [super initWithStyle:UITableViewStylePlain]; + if (self) { + title_id_ = title_id; + game_title_ = [title copy]; + compat_info_ = [compat_data retain]; + hero_view_ = [[XeniaGameCompatibilityHeroView alloc] initWithTitleID:title_id + title:title + closeTarget:self + closeAction:@selector(doneTapped:)]; + discussion_controller_ = [[XeniaGameCompatibilityDiscussionController alloc] + initWithTitleID:title_id + compatInfo:compat_data]; + self.title = @"Compatibility"; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [hero_view_ hideAndRemoveFromSuperview]; + [game_title_ release]; + [compat_info_ release]; + [hero_view_ release]; + [discussion_controller_ release]; + [super dealloc]; +} + +- (void)setHeroArtwork:(UIImage*)image { + [hero_view_ setHeroArtwork:image]; + if ([self isViewLoaded]) { + [self layoutHeroHeaderIfNeeded]; + } +} + +- (NSDictionary*)bestResultSource { + NSDictionary* preferred_summary = xe_preferred_summary_from_compat_info(compat_info_); + return preferred_summary ?: [self latestDiscussionReport]; +} + +- (void)refreshHeroContent { + [hero_view_ setCompatInfo:compat_info_ summarySource:[self bestResultSource]]; +} + +- (void)layoutHeroHeaderIfNeeded { + UIView* host_view = self.navigationController.view ?: self.view.superview ?: self.view; + [hero_view_ layoutInTableView:self.tableView controllerView:self.view hostView:host_view]; +} + +- (void)buildHeroHeaderIfNeeded { + [self refreshHeroContent]; + [hero_view_ buildIfNeededWithTableView:self.tableView controllerView:self.view]; + [self layoutHeroHeaderIfNeeded]; +} + +- (void)loadHeroArtwork { + [hero_view_ loadArtworkIfNeeded]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [XeniaTheme bgPrimary]; + self.tableView.backgroundColor = [UIColor clearColor]; + self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 360.0; + self.tableView.alwaysBounceVertical = YES; + if (@available(iOS 11.0, *)) { + self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + if (@available(iOS 15.0, *)) { + self.tableView.sectionHeaderTopPadding = 0; + } + self.title = @""; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onDiscussionDidUpdate:) + name:kXeniaDiscussionDidUpdateNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onCompatDataDidUpdate:) + name:kXeniaCompatDataDidUpdateNotification + object:nil]; + discussion_controller_.delegate = self; + [self buildHeroHeaderIfNeeded]; + [self loadHeroArtwork]; + [discussion_controller_ loadFromCompatibilityData]; +} + +- (void)viewSafeAreaInsetsDidChange { + [super viewSafeAreaInsetsDidChange]; + // Add bottom safe area inset so the Submit Report card isn't clipped + // behind the home indicator. + UIEdgeInsets insets = self.tableView.contentInset; + CGFloat safe_bottom = 0.0; + if (@available(iOS 11.0, *)) { + safe_bottom = self.view.safeAreaInsets.bottom; + } + if (fabs(insets.bottom - safe_bottom) > 0.5) { + insets.bottom = safe_bottom; + self.tableView.contentInset = insets; + } +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if (![self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + return; + } + // The persistent hero header card has its border colour captured as a + // CGColor, and the table cells re-render their cards on reload from the + // dynamic XeniaTheme accessors. + [hero_view_ updateTraitColors]; + [self.tableView reloadData]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.navigationController setNavigationBarHidden:YES animated:NO]; + [self layoutHeroHeaderIfNeeded]; + + id coordinator = self.transitionCoordinator; + if (coordinator && coordinator.interactive) { + // Interactive swipe-back: don't show hero yet — wait for completion. + [hero_view_ setHidden:YES]; + [coordinator notifyWhenInteractionChangesUsingBlock:^( + id context) { + if (context.isCancelled) { + // Swipe was cancelled — keep hidden, viewWillDisappear will handle. + return; + } + // Swipe committed — show hero now. + [self layoutHeroHeaderIfNeeded]; + [self->hero_view_ layoutOverlayFrames]; + [self->hero_view_ setHidden:NO]; + }]; + } else { + // Non-interactive transition (back button, programmatic pop): + // show immediately. + [hero_view_ setHidden:NO]; + } +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self.navigationController setNavigationBarHidden:YES animated:NO]; + [self layoutHeroHeaderIfNeeded]; + [hero_view_ layoutOverlayFrames]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [hero_view_ hideAndRemoveFromSuperview]; + [hero_view_ layoutOverlayFrames]; + [hero_view_ updateGradientFrames]; + [hero_view_ ensureTopGlowAnimation]; + [self.navigationController setNavigationBarHidden:NO animated:NO]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self layoutHeroHeaderIfNeeded]; +} + +- (void)doneTapped:(id)__unused sender { + [hero_view_ hideAndRemoveFromSuperview]; + if (self.navigationController.presentingViewController && + self.navigationController.viewControllers.firstObject == self) { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; + return; + } + [self.navigationController setNavigationBarHidden:NO animated:NO]; + [self.navigationController popViewControllerAnimated:YES]; +} + +- (void)submitReportTapped:(id)__unused sender { + [hero_view_ hideAndRemoveFromSuperview]; + XeniaCompatReportViewController* report_controller = + [[XeniaCompatReportViewController alloc] initWithTitleID:title_id_ title:game_title_]; + [self.navigationController pushViewController:report_controller animated:YES]; + [report_controller release]; +} + +- (void)onCompatDataDidUpdate:(NSNotification*)notification { + NSNumber* updated_title_id = notification.userInfo[@"titleId"]; + if (![updated_title_id isKindOfClass:[NSNumber class]] || + [updated_title_id unsignedIntValue] != title_id_) { + return; + } + + NSDictionary* next_info = xe_dictionary_from_object(notification.userInfo[@"compatInfo"]); + if (!next_info) { + NSDictionary* cached_by_title_id = xe_load_cached_compat_data(); + NSString* title_id_string = XEFormatTitleIDHexUpper(title_id_); + next_info = xe_dictionary_from_object(cached_by_title_id[title_id_string]); + } + if (!next_info) { + return; + } + + [compat_info_ release]; + compat_info_ = [next_info retain]; + [discussion_controller_ setCompatInfo:next_info]; + [self refreshHeroContent]; + [self.tableView reloadData]; +} + +- (void)onDiscussionDidUpdate:(NSNotification*)notification { + [discussion_controller_ handleDiscussionNotification:notification]; +} + +- (void)compatibilityDiscussionControllerDidUpdate: + (XeniaGameCompatibilityDiscussionController*)__unused controller { + [self refreshHeroContent]; + [self.tableView reloadData]; +} + +- (NSDictionary*)latestDiscussionReport { + return [discussion_controller_ latestReport]; +} + +- (void)viewIssueTapped:(id)__unused sender { + NSString* issue_url = discussion_controller_.issueURL; + if (!issue_url) { + return; + } + NSURL* url = [NSURL URLWithString:issue_url]; + if (!url) { + return; + } + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; +} + +- (void)toggleDiscussionExpansionTapped:(id)__unused sender { + [discussion_controller_ toggleShowAll]; +} + +- (void)toggleDiscussionReportExpansionTapped:(UIButton*)sender { + if (!sender) { + return; + } + [discussion_controller_ toggleReportExpandedAtIndex:sender.tag]; + NSIndexPath* discussion_path = [NSIndexPath indexPathForRow:0 inSection:0]; + [self.tableView reloadRowsAtIndexPaths:@[ discussion_path ] + withRowAnimation:UITableViewRowAnimationFade]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)__unused section { + return 3; +} + +- (UITableViewCell*)tableView:(UITableView* __unused)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.row == 0) { + return [XeniaGameCompatibilityCells + discussionCellWithReports:discussion_controller_.reports + expandedReportIndexes:discussion_controller_.expandedReportIndexes + loading:discussion_controller_.loading + showAll:discussion_controller_.showAll + issueURL:discussion_controller_.issueURL + issueNumber:discussion_controller_.issueNumber + target:self + viewIssueAction:@selector(viewIssueTapped:) + toggleExpansionAction:@selector(toggleDiscussionExpansionTapped:) + toggleReportAction:@selector(toggleDiscussionReportExpansionTapped:)]; + } + if (indexPath.row == 1) { + return [XeniaGameCompatibilityCells detailsCellWithCompatInfo:compat_info_ + latestReport:[self latestDiscussionReport]]; + } + return [XeniaGameCompatibilityCells ctaCellWithTarget:self + submitAction:@selector(submitReportTapped:)]; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_content_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_content_view_controller.h new file mode 100644 index 000000000..21bd369f7 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_content_view_controller.h @@ -0,0 +1,38 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_CONTENT_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_GAME_CONTENT_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Hosts the Manage Content sheet. The launcher's main view controller adopts +// this protocol so the sheet can ask it to install a freshly-imported title +// update via the same code path that the library's import flow uses. +@protocol XeniaGameContentHost +- (BOOL)installTitleUpdateAtPath:(NSString*)path + status:(NSString**)status_out + notTitleUpdate:(BOOL*)not_title_update_out; +- (void)refreshImportedGames; +@end + +// Per-game content management sheet: lists installed title updates and DLC +// for `title_id`, and adds new ones via UIDocumentPicker. Calls back into the +// launcher (`host`) to refresh the library when installs succeed. +@interface XeniaGameContentViewController : XESheetTableViewController +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + host:(id)host; +@end + +#endif // XENIA_UI_IOS_GAME_CONTENT_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_content_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_content_view_controller.mm new file mode 100644 index 000000000..c57b6fddd --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_content_view_controller.mm @@ -0,0 +1,305 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_content_view_controller.h" + +#import + +#include +#include +#include + +#include "xenia/base/logging.h" +#include "xenia/xbox.h" + +#import "xenia/ui/ios/launcher/ios_content_management.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaGameContentViewController { + uint32_t title_id_; + NSString* game_title_; + id host_; // not retained; presenter owns this sheet + std::vector installed_content_; +} + +- (instancetype)initWithTitleID:(uint32_t)title_id + title:(NSString*)title + host:(id)host { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + title_id_ = title_id; + game_title_ = [title copy]; + host_ = host; + self.title = @"Manage Content"; + } + return self; +} + +- (void)dealloc { + [game_title_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 16, 0, 16); + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithTitle:@"Close" + style:UIBarButtonItemStylePlain + target:self + action:@selector(doneTapped:)] autorelease]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self reloadInstalledContent]; +} + +- (void)doneTapped:(id)__unused sender { + if (self.navigationController.presentingViewController && + self.navigationController.viewControllers.firstObject == self) { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; + return; + } + [self.navigationController popViewControllerAnimated:YES]; +} + +- (void)reloadInstalledContent { + installed_content_ = xe_list_installed_content(title_id_); + [self.tableView reloadData]; +} + +- (void)refreshLauncherContentState { + if (host_) { + [host_ refreshImportedGames]; + } + [self reloadInstalledContent]; +} + +- (void)presentAddContentPicker { + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[ UTTypeData ]]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + picker.shouldShowFileExtensions = YES; + [self presentViewController:picker animated:YES completion:nil]; + [picker release]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 2; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView numberOfRowsInSection:(NSInteger)section { + if (section == 0) { + return 1; + } + return installed_content_.empty() ? 1 : static_cast(installed_content_.size()); +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForHeaderInSection:(NSInteger)section { + if (section == 0) { + return @"Actions"; + } + return @"Installed Content"; +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForFooterInSection:(NSInteger)section { + if (section == 0) { + if (game_title_.length > 0) { + return + [NSString stringWithFormat:@"Install title updates or DLC packages for %@.", game_title_]; + } + return @"Install title updates or DLC packages for this game."; + } + if (installed_content_.empty()) { + return @"No title updates or DLC are installed for this title."; + } + return @"Swipe left on an installed entry to delete it."; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.section == 0) { + static NSString* const kActionCellIdentifier = @"XeniaGameContentActionCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kActionCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kActionCellIdentifier] autorelease]; + } + cell.textLabel.text = @"Add Content"; + cell.textLabel.textColor = self.view.tintColor; + cell.detailTextLabel.text = @"Import a title update or DLC package for this game."; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.imageView.image = [UIImage systemImageNamed:@"plus.circle.fill"]; + cell.imageView.tintColor = self.view.tintColor; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + return cell; + } + + if (installed_content_.empty()) { + static NSString* const kEmptyCellIdentifier = @"XeniaGameContentEmptyCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kEmptyCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kEmptyCellIdentifier] autorelease]; + } + cell.textLabel.text = @"No content installed"; + cell.detailTextLabel.text = @"Add a title update or DLC package from Files."; + cell.textLabel.textColor = [XeniaTheme textMuted]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + static NSString* const kContentCellIdentifier = @"XeniaGameContentCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kContentCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kContentCellIdentifier] autorelease]; + } + + const IOSInstalledContentEntry& entry = installed_content_[static_cast(indexPath.row)]; + cell.textLabel.text = ToNSString(entry.name); + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = xe_installed_content_kind_label(entry.kind); + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; +} + +- (BOOL)tableView:(UITableView* __unused)tableView canEditRowAtIndexPath:(NSIndexPath*)indexPath { + return indexPath.section == 1 && !installed_content_.empty(); +} + +- (void)tableView:(UITableView* __unused)tableView + commitEditingStyle:(UITableViewCellEditingStyle)editingStyle + forRowAtIndexPath:(NSIndexPath*)indexPath { + if (editingStyle != UITableViewCellEditingStyleDelete || indexPath.section != 1 || + installed_content_.empty()) { + return; + } + + const IOSInstalledContentEntry& entry = installed_content_[static_cast(indexPath.row)]; + const std::filesystem::path entry_path = entry.path; + NSString* display_name = ToNSString(entry.name); + UIAlertController* confirm = [UIAlertController + alertControllerWithTitle:@"Delete Content" + message:[NSString stringWithFormat:@"Delete \"%@\"? This cannot be undone.", + display_name] + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + [confirm + addAction:[UIAlertAction + actionWithTitle:@"Delete" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction* action) { + std::error_code ec; + std::filesystem::remove_all(entry_path, ec); + if (ec) { + XEPresentOKAlert( + self, @"Delete Failed", + [NSString stringWithFormat:@"Failed deleting %@: %s", + display_name, ec.message().c_str()]); + return; + } + [self refreshLauncherContentState]; + }]]; + [self presentViewController:confirm animated:YES completion:nil]; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 0) { + [self presentAddContentPicker]; + } +} + +- (void)documentPicker:(UIDocumentPickerViewController* __unused)controller + didPickDocumentsAtURLs:(NSArray*)urls { + if (urls.count == 0) { + return; + } + + NSURL* url = urls[0]; + BOOL access_granted = [url startAccessingSecurityScopedResource]; + IOSSelectedContentPackage package_info; + NSString* validation_error = nil; + if (!xe_read_selected_content_package(std::filesystem::path([url.path UTF8String]), &package_info, + &validation_error)) { + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + XEPresentOKAlert(self, @"Invalid Package", + validation_error ?: @"Could not read the selected content package."); + return; + } + + if (package_info.title_id != title_id_) { + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + XEPresentOKAlert( + self, @"Wrong Game", + [NSString stringWithFormat:@"This package is for title %08X, but the current game is %08X.", + package_info.title_id, title_id_]); + return; + } + + BOOL install_success = NO; + NSString* result_title = @"Unsupported Content"; + NSString* result_message = nil; + switch (package_info.content_type) { + case xe::XContentType::kInstaller: { + NSString* status_message = nil; + install_success = host_ && [host_ installTitleUpdateAtPath:url.path + status:&status_message + notTitleUpdate:nullptr]; + result_title = install_success ? @"Installed" : @"Install Failed"; + result_message = install_success ? (status_message ?: @"Title update installed successfully.") + : (status_message ?: @"Title update installation failed."); + } break; + case xe::XContentType::kMarketplaceContent: { + std::string error_message; + install_success = xe_copy_content_package_into_root( + package_info, xe_dlc_content_root(title_id_), &error_message); + result_title = install_success ? @"Installed" : @"Install Failed"; + result_message = + install_success + ? @"DLC installed successfully." + : ToNSString(error_message.empty() ? "DLC installation failed." : error_message); + } break; + default: + result_message = + [NSString stringWithFormat:@"Content type 0x%08X is not a title update or DLC.", + static_cast(package_info.content_type)]; + break; + } + + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + + if (install_success) { + [self refreshLauncherContentState]; + } + XEPresentOKAlert(self, result_title, result_message); +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController* __unused)controller { + XELOGI("iOS: Manage Content picker cancelled"); +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.h new file mode 100644 index 000000000..ee7d976d7 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.h @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_DISC_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_GAME_DISC_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/ios/launcher/ios_game_library.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Lets users choose a specific launch path for multi-disc titles while the +// launcher still presents the game as one library entry. +@interface XeniaGameDiscViewController : XESheetTableViewController +- (instancetype)initWithTitle:(NSString*)title + discs:(const std::vector&)discs + selectionHandler:(void (^)(NSString* path, NSString* label))selectionHandler; +@end + +#endif // XENIA_UI_IOS_GAME_DISC_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.mm new file mode 100644 index 000000000..40f22e9ae --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_disc_view_controller.mm @@ -0,0 +1,151 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_disc_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +NSString* DetailForDisc(const xe::ui::IOSDiscoveredGame::Disc& disc) { + NSString* filename = ToNSString(disc.path.filename().string()); + if (disc.media_id) { + return [NSString stringWithFormat:@"%@ - Media %08X", filename, + disc.media_id]; + } + return filename; +} + +} // namespace + +@implementation XeniaGameDiscViewController { + NSString* title_; + std::vector discs_; + void (^selection_handler_)(NSString* path, NSString* label); +} + +- (instancetype)initWithTitle:(NSString*)title + discs: + (const std::vector&)discs + selectionHandler:(void (^)(NSString* path, NSString* label))selectionHandler { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + title_ = [title copy]; + discs_ = discs; + selection_handler_ = [selectionHandler copy]; + self.title = @"Launch Disc"; + } + return self; +} + +- (void)dealloc { + [title_ release]; + [selection_handler_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(doneTapped:)] autorelease]; +} + +- (void)doneTapped:(id)__unused sender { + if (self.navigationController.presentingViewController && + self.navigationController.viewControllers.firstObject == self) { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; + return; + } + [self.navigationController popViewControllerAnimated:YES]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)__unused section { + return discs_.empty() ? 1 : static_cast(discs_.size()); +} + +- (NSString*)tableView:(UITableView* __unused)tableView + titleForHeaderInSection:(NSInteger)__unused section { + return title_.length ? title_ : @"Game"; +} + +- (NSString*)tableView:(UITableView* __unused)tableView + titleForFooterInSection:(NSInteger)__unused section { + if (discs_.empty()) { + return @"No alternate discs were found for this title."; + } + return @"Choose the disc to boot. Saves, title updates, and installed content " + @"remain shared by title ID."; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kDiscCellIdentifier = @"XeniaGameDiscCell"; + UITableViewCell* cell = + [tableView dequeueReusableCellWithIdentifier:kDiscCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kDiscCellIdentifier] autorelease]; + } + + if (discs_.empty()) { + cell.textLabel.text = @"No discs available"; + cell.detailTextLabel.text = nil; + cell.textLabel.textColor = [XeniaTheme textMuted]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + const auto& disc = discs_[static_cast(indexPath.row)]; + cell.textLabel.text = ToNSString(disc.label); + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = DetailForDisc(disc); + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.imageView.image = [UIImage systemImageNamed:@"opticaldisc"]; + cell.imageView.tintColor = self.view.tintColor; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + cell.accessibilityLabel = cell.textLabel.text; + cell.accessibilityValue = cell.detailTextLabel.text; + cell.accessibilityHint = @"Launches this disc."; + cell.accessibilityTraits = UIAccessibilityTraitButton; + return cell; +} + +- (void)tableView:(UITableView*)tableView + didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (discs_.empty() || static_cast(indexPath.row) >= discs_.size()) { + return; + } + const auto& disc = discs_[static_cast(indexPath.row)]; + NSString* path = [ToNSString(disc.path.string()) copy]; + NSString* label = [ToNSString(disc.label) copy]; + void (^handler)(NSString*, NSString*) = [selection_handler_ copy]; + [self.navigationController dismissViewControllerAnimated:YES + completion:^{ + if (handler) { + handler(path, label); + } + [handler release]; + [path release]; + [label release]; + }]; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_library.h b/src/xenia/ui/ios/launcher/ios_game_library.h new file mode 100644 index 000000000..76f8dd971 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_library.h @@ -0,0 +1,69 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_LIBRARY_H_ +#define XENIA_UI_IOS_GAME_LIBRARY_H_ + +#include +#include +#include +#include + +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#include "xenia/xbox.h" + +namespace xe { +namespace ui { + +struct IOSDiscoveredGame { + std::filesystem::path path; + std::string title; + IOSGameSystem system = IOSGameSystem::kXbox360; + XContentType content_type = XContentType::kInvalid; + std::string content_type_name; + uint32_t title_id = 0; + uint32_t media_id = 0; + uint8_t disc_number = 0; + uint8_t disc_count = 0; + std::vector icon_data; + bool has_compat_info = false; + std::string compat_status; + std::string compat_perf; + std::string compat_notes; + bool has_installed_content = false; + struct Disc { + std::filesystem::path path; + std::string label; + uint32_t media_id = 0; + uint8_t disc_number = 0; + uint8_t disc_count = 0; + }; + std::vector discs; +}; + +std::string ToLowerAsciiCopy(std::string value); +bool IsISOPath(const std::filesystem::path& path); +bool IsDefaultXexPath(const std::filesystem::path& path); +bool IsDefaultXbePath(const std::filesystem::path& path); +bool IsLikelyGodContainerFile(const std::filesystem::path& path); +bool IsIOSLaunchableContentType(XContentType content_type); +std::string IOSContentTypeDisplayName(XContentType content_type); +void SortDiscoveredGames(std::vector* games); +std::string FormatTitleID(uint32_t title_id); +std::string NormalizeGameTitleForUI(const std::string& title); +void EnsureDiscoveredGameDiscList(IOSDiscoveredGame* game); +void MergeDiscoveredGameDisc(IOSDiscoveredGame* game, + const IOSDiscoveredGame& disc_game); +bool BuildDiscoveredGameFromPath(const std::filesystem::path& path, + IOSDiscoveredGame* game_out); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_GAME_LIBRARY_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_library.mm b/src/xenia/ui/ios/launcher/ios_game_library.mm new file mode 100644 index 000000000..4b262dd7d --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_library.mm @@ -0,0 +1,369 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/launcher/ios_game_library.h" + +#include +#include +#include +#include +#include + +#include "xenia/base/logging.h" +#include "xenia/base/string.h" +#include "xenia/ui/ios/launcher/ios_content_management.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" +#include "xenia/vfs/iso_metadata.h" +#include "xenia/vfs/stfs_metadata.h" +#include "xenia/vfs/xex_metadata.h" +#include "xenia/xbox.h" + +namespace xe { +namespace ui { + +std::string ToLowerAsciiCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return std::tolower(c); }); + return value; +} + +bool LooksLikeHexIdentifier(const std::string& value, size_t min_length = 8, + size_t max_length = 32) { + if (value.size() < min_length || value.size() > max_length) { + return false; + } + return std::all_of(value.begin(), value.end(), + [](unsigned char c) { return std::isxdigit(c) != 0; }); +} + +bool IsISOPath(const std::filesystem::path& path) { + return ToLowerAsciiCopy(path.extension().string()) == ".iso"; +} + +bool IsDefaultXexPath(const std::filesystem::path& path) { + return ToLowerAsciiCopy(path.filename().string()) == "default.xex"; +} + +bool IsDefaultXbePath(const std::filesystem::path& path) { + return ToLowerAsciiCopy(path.filename().string()) == "default.xbe"; +} + +bool IsLaunchableXContentDirectoryName(const std::string& name_lower) { + return name_lower == "00004000" || // Installed Game / GOD + name_lower == "00007000" || // Xbox 360 Title + name_lower == "00080000" || // Game Demo + name_lower == "000d0000" || // XBLA + name_lower == "02000000"; // Community Game +} + +bool IsLikelyLaunchableXContentPath(const std::filesystem::path& path) { + if (!path.has_filename()) { + return false; + } + std::filesystem::path parent = path.parent_path(); + while (!parent.empty()) { + std::string name_lower = ToLowerAsciiCopy(parent.filename().string()); + if (IsLaunchableXContentDirectoryName(name_lower)) { + return true; + } + std::filesystem::path next = parent.parent_path(); + if (next == parent) { + break; + } + parent = next; + } + return false; +} + +bool LooksLikeHexContentFilename(const std::filesystem::path& path) { + const std::string filename = path.filename().string(); + return LooksLikeHexIdentifier(filename, 8, 40); +} + +bool IsLikelyGodContainerFile(const std::filesystem::path& path) { + if (IsLikelyLaunchableXContentPath(path)) { + return true; + } + if (path.has_extension()) { + return false; + } + return LooksLikeHexContentFilename(path); +} + +bool IsIOSLaunchableContentType(XContentType content_type) { + switch (content_type) { + case XContentType::kInstalledGame: + case XContentType::kXbox360Title: + case XContentType::kArcadeTitle: + case XContentType::kGameDemo: + case XContentType::kCommunityGame: + return true; + default: + return false; + } +} + +std::string IOSContentTypeDisplayName(XContentType content_type) { + switch (content_type) { + case XContentType::kArcadeTitle: + return "XBLA"; + case XContentType::kGameDemo: + return "Demo"; + case XContentType::kCommunityGame: + return "Community Game"; + case XContentType::kInstalledGame: + return "Installed Game"; + case XContentType::kXbox360Title: + return "Xbox 360 Title"; + default: + return std::string(); + } +} + +std::string LibraryFallbackTitleFromPath(const std::filesystem::path& path) { + if (IsDefaultXexPath(path) || IsDefaultXbePath(path)) { + std::filesystem::path parent = path.parent_path(); + while (!parent.empty()) { + std::string candidate = parent.filename().string(); + std::string candidate_lower = ToLowerAsciiCopy(candidate); + if (!candidate.empty() && !LooksLikeHexIdentifier(candidate) && + candidate_lower != "content" && candidate_lower != "games" && + candidate_lower != "files" && candidate_lower != "default") { + return candidate; + } + std::filesystem::path next = parent.parent_path(); + if (next == parent) { + break; + } + parent = next; + } + } + + std::string stem = path.stem().string(); + if (!stem.empty()) { + return stem; + } + return path.filename().string(); +} + +NSString* NormalizeGameTitleForUI(NSString* title) { + if (!title || title.length == 0) { + return title; + } + if ([title rangeOfCharacterFromSet:[NSCharacterSet whitespaceCharacterSet]].location != + NSNotFound) { + return title; + } + NSRange letter_range = [title rangeOfCharacterFromSet:[NSCharacterSet letterCharacterSet]]; + if (letter_range.location == NSNotFound) { + return title; + } + NSRange lower_range = + [title rangeOfCharacterFromSet:[NSCharacterSet lowercaseLetterCharacterSet]]; + if (lower_range.location != NSNotFound) { + return title; + } + NSCharacterSet* roman_set = [NSCharacterSet characterSetWithCharactersInString:@"IVXLCDM"]; + NSCharacterSet* non_roman_set = [roman_set invertedSet]; + if ([title rangeOfCharacterFromSet:non_roman_set].location == NSNotFound) { + return title; + } + return [title localizedCapitalizedString]; +} + +std::string DisplayNameFromXexMetadata(const std::filesystem::path& path, + const std::optional& metadata) { + if (metadata.has_value() && !metadata->module_name.empty() && !IsDefaultXexPath(path)) { + return metadata->module_name; + } + return LibraryFallbackTitleFromPath(path); +} + +void SortDiscoveredGames(std::vector* games) { + if (!games) { + return; + } + std::sort(games->begin(), games->end(), + [](const IOSDiscoveredGame& a, const IOSDiscoveredGame& b) { + if (a.title == b.title) { + return a.path.filename().string() < b.path.filename().string(); + } + return a.title < b.title; + }); +} + +std::string FormatTitleID(uint32_t title_id) { + if (!title_id) { + return std::string(); + } + char buffer[9] = {}; + std::snprintf(buffer, sizeof(buffer), "%08X", title_id); + return std::string(buffer); +} + +std::string DiscLabelForGame(const IOSDiscoveredGame& game) { + if (game.disc_number > 0 && game.disc_count > 1) { + return "Disc " + std::to_string(game.disc_number) + " of " + std::to_string(game.disc_count); + } + if (game.disc_number > 0) { + return "Disc " + std::to_string(game.disc_number); + } + std::string filename = game.path.filename().string(); + return filename.empty() ? "Launch Item" : filename; +} + +IOSDiscoveredGame::Disc DiscFromGame(const IOSDiscoveredGame& game) { + IOSDiscoveredGame::Disc disc; + disc.path = game.path; + disc.label = DiscLabelForGame(game); + disc.media_id = game.media_id; + disc.disc_number = game.disc_number; + disc.disc_count = game.disc_count; + return disc; +} + +bool DiscSortLess(const IOSDiscoveredGame::Disc& a, const IOSDiscoveredGame::Disc& b) { + const bool a_numbered = a.disc_number > 0; + const bool b_numbered = b.disc_number > 0; + if (a_numbered != b_numbered) { + return a_numbered; + } + if (a.disc_number != b.disc_number) { + return a.disc_number < b.disc_number; + } + if (a.media_id != b.media_id) { + return a.media_id < b.media_id; + } + return a.path.filename().string() < b.path.filename().string(); +} + +void EnsureDiscoveredGameDiscList(IOSDiscoveredGame* game) { + if (!game || game->path.empty()) { + return; + } + if (game->discs.empty()) { + game->discs.push_back(DiscFromGame(*game)); + } + std::sort(game->discs.begin(), game->discs.end(), DiscSortLess); +} + +void MergeDiscoveredGameDisc(IOSDiscoveredGame* game, const IOSDiscoveredGame& disc_game) { + if (!game || disc_game.path.empty()) { + return; + } + EnsureDiscoveredGameDiscList(game); + std::vector incoming = disc_game.discs; + if (incoming.empty()) { + incoming.push_back(DiscFromGame(disc_game)); + } + for (IOSDiscoveredGame::Disc& disc : incoming) { + auto existing = std::find_if( + game->discs.begin(), game->discs.end(), + [&](const IOSDiscoveredGame::Disc& candidate) { return candidate.path == disc.path; }); + if (existing == game->discs.end()) { + game->discs.push_back(std::move(disc)); + } + } + std::sort(game->discs.begin(), game->discs.end(), DiscSortLess); +} + +std::string NormalizeGameTitleForUI(const std::string& title) { + NSString* normalized = NormalizeGameTitleForUI(ToNSString(title)); + return normalized ? std::string([normalized UTF8String]) : title; +} + +bool BuildDiscoveredGameFromPath(const std::filesystem::path& path, IOSDiscoveredGame* game_out) { + if (!game_out) { + return false; + } + + IOSDiscoveredGame game; + game.path = path; + if (IsISOPath(path)) { + auto metadata = xe::vfs::ExtractIsoMetadata(path); + if (metadata.has_value()) { + game.system = IOSGameSystem::kXbox360; + game.title_id = metadata->title_id; + game.media_id = metadata->media_id; + game.disc_number = metadata->disc_number; + game.disc_count = metadata->disc_count; + game.title = NormalizeGameTitleForUI(DisplayNameFromXexMetadata(path, metadata)); + EnsureDiscoveredGameDiscList(&game); + *game_out = std::move(game); + return true; + } + + game.system = IOSGameSystem::kXbox360; + game.title = NormalizeGameTitleForUI(LibraryFallbackTitleFromPath(path)); + EnsureDiscoveredGameDiscList(&game); + *game_out = std::move(game); + return true; + } + + if (IsDefaultXexPath(path)) { + game.system = IOSGameSystem::kXbox360; + auto metadata = xe::vfs::ExtractXexMetadata(path); + if (metadata.has_value()) { + game.title_id = metadata->title_id; + game.media_id = metadata->media_id; + game.disc_number = metadata->disc_number; + game.disc_count = metadata->disc_count; + } + game.title = NormalizeGameTitleForUI(DisplayNameFromXexMetadata(path, metadata)); + EnsureDiscoveredGameDiscList(&game); + *game_out = std::move(game); + return true; + } + + if (!IsLikelyGodContainerFile(path)) { + return false; + } + + auto metadata = xe::vfs::ExtractStfsMetadata(path); + if (!metadata.has_value()) { + return false; + } + + if (metadata->data_file_count > 0 && !HasContentSidecarDataDirectory(path)) { + XELOGW("iOS: Skipping XContent package missing .data sidecar: {}", path); + return false; + } + + xe::XContentType content_type = static_cast(metadata->content_type); + if (!IsIOSLaunchableContentType(content_type)) { + return false; + } + + game.system = IOSGameSystem::kXbox360; + game.content_type = content_type; + game.content_type_name = IOSContentTypeDisplayName(content_type); + game.title_id = metadata->title_id; + game.media_id = metadata->media_id; + game.disc_number = metadata->disc_number; + game.disc_count = metadata->disc_count; + std::string display_name = metadata->display_name; + if (display_name.empty()) { + display_name = metadata->title_name; + } + if (display_name.empty()) { + game.title = LibraryFallbackTitleFromPath(path); + } else { + game.title = display_name; + } + game.title = NormalizeGameTitleForUI(game.title); + + game.icon_data = metadata->icon_data; + + EnsureDiscoveredGameDiscList(&game); + *game_out = std::move(game); + return true; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/launcher/ios_game_library_store.h b/src/xenia/ui/ios/launcher/ios_game_library_store.h new file mode 100644 index 000000000..a568b31f9 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_library_store.h @@ -0,0 +1,35 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_LIBRARY_STORE_H_ +#define XENIA_UI_IOS_GAME_LIBRARY_STORE_H_ + +#import + +#include +#include + +#include "xenia/ui/ios/launcher/ios_game_library.h" + +namespace xe { +namespace ui { + +std::filesystem::path IOSImportedGamesDirectory(); + +std::filesystem::path ImportGameIntoIOSLibrary(NSURL* source_url, NSError** error); + +std::vector ScanIOSGameLibrary(NSDictionary* title_name_cache); + +void ApplyIOSCompatibilityDataToDiscoveredGames(NSDictionary* compat_data, + std::vector* games); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_GAME_LIBRARY_STORE_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_library_store.mm b/src/xenia/ui/ios/launcher/ios_game_library_store.mm new file mode 100644 index 000000000..21a3b2f34 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_library_store.mm @@ -0,0 +1,243 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_library_store.h" + +#include +#include +#include +#include + +#include "xenia/ui/ios/launcher/ios_compat_data.h" +#include "xenia/ui/ios/launcher/ios_content_management.h" +#include "xenia/ui/ios/shared/ios_system_utils.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace xe { +namespace ui { + +namespace { + +int LibraryFormatPriority(const std::filesystem::path& path) { + if (IsLikelyGodContainerFile(path)) { + return 0; + } + if (IsISOPath(path)) { + return 1; + } + return 2; +} + +} // namespace + +std::filesystem::path IOSImportedGamesDirectory() { + return xe_get_ios_documents_path() / "games"; +} + +std::filesystem::path ImportGameIntoIOSLibrary(NSURL* source_url, NSError** error) { + std::filesystem::path source_path([source_url.path UTF8String]); + std::filesystem::path library_path = IOSImportedGamesDirectory(); + + std::error_code ec; + std::filesystem::create_directories(library_path, ec); + if (ec) { + if (error) { + *error = [NSError + errorWithDomain:@"XeniaIOSImport" + code:1001 + userInfo:@{ + NSLocalizedDescriptionKey : [NSString + stringWithFormat:@"Failed creating library folder: %s", ec.message().c_str()] + }]; + } + return {}; + } + + auto weak_source = std::filesystem::weakly_canonical(source_path, ec); + auto weak_library = std::filesystem::weakly_canonical(library_path, ec); + if (!ec && weak_source.native().rfind(weak_library.native(), 0) == 0) { + return weak_source; + } + + std::filesystem::path destination = library_path / source_path.filename(); + std::filesystem::path stem = destination.stem(); + std::filesystem::path extension = destination.extension(); + for (int attempt = 2; std::filesystem::exists(destination); ++attempt) { + destination = + library_path / std::filesystem::path(stem.string() + " (" + std::to_string(attempt) + ")" + + extension.string()); + } + + NSString* source_ns = source_url.path; + NSString* destination_ns = ToNSString(destination.string()); + if (![[NSFileManager defaultManager] copyItemAtPath:source_ns + toPath:destination_ns + error:error]) { + return {}; + } + + if (HasContentSidecarDataDirectory(source_path)) { + std::filesystem::path source_sidecar = source_path; + source_sidecar += ".data"; + std::filesystem::path destination_sidecar = destination; + destination_sidecar += ".data"; + + std::string error_message; + if (!xe_copy_directory_recursive(source_sidecar, destination_sidecar, &error_message)) { + std::error_code cleanup_error; + std::filesystem::remove(destination, cleanup_error); + std::filesystem::remove_all(destination_sidecar, cleanup_error); + if (error) { + *error = [NSError + errorWithDomain:@"XeniaIOSImport" + code:1002 + userInfo:@{ + NSLocalizedDescriptionKey : ToNSString( + error_message.empty() ? "Failed copying package sidecar." : error_message) + }]; + } + return {}; + } + } + + return destination; +} + +std::vector ScanIOSGameLibrary(NSDictionary* title_name_cache) { + std::vector games; + + std::vector scan_roots; + const std::filesystem::path documents_root = xe_get_ios_documents_path(); + const std::filesystem::path library_root = IOSImportedGamesDirectory(); + scan_roots.push_back(library_root); + if (documents_root != library_root) { + scan_roots.push_back(documents_root); + } + + std::set seen_paths; + std::map title_id_to_index; + for (const auto& root : scan_roots) { + std::error_code ec; + if (!std::filesystem::exists(root, ec)) { + continue; + } + + std::filesystem::recursive_directory_iterator it( + root, std::filesystem::directory_options::skip_permission_denied, ec); + std::filesystem::recursive_directory_iterator end; + while (!ec && it != end) { + const auto& entry = *it; + const auto filename = entry.path().filename().string(); + const auto filename_lower = ToLowerAsciiCopy(filename); + if (entry.is_directory(ec)) { + if (filename_lower == "cache" || filename_lower == "cache_host") { + it.disable_recursion_pending(); + } + } else if (entry.is_regular_file(ec) && + (IsISOPath(entry.path()) || IsDefaultXexPath(entry.path()) || + IsLikelyGodContainerFile(entry.path()))) { + const std::filesystem::path canonical_path = + std::filesystem::weakly_canonical(entry.path(), ec); + const std::filesystem::path unique_path = + ec ? std::filesystem::absolute(entry.path(), ec) : canonical_path; + ec.clear(); + + if (seen_paths.insert(unique_path).second) { + IOSDiscoveredGame game; + if (!BuildDiscoveredGameFromPath(unique_path, &game)) { + ++it; + continue; + } + if (game.title_id && title_name_cache) { + NSString* key = XEFormatTitleIDHexLower(game.title_id); + NSString* cached = [title_name_cache objectForKey:key]; + if (cached.length > 0) { + game.title = NormalizeGameTitleForUI(std::string([cached UTF8String])); + } + } + if (game.title_id) { + auto existing = title_id_to_index.find(game.title_id); + if (existing != title_id_to_index.end()) { + IOSDiscoveredGame& existing_game = games[existing->second]; + int old_priority = LibraryFormatPriority(existing_game.path); + int new_priority = LibraryFormatPriority(unique_path); + if (new_priority < old_priority) { + IOSDiscoveredGame previous_game = std::move(existing_game); + existing_game = std::move(game); + MergeDiscoveredGameDisc(&existing_game, previous_game); + } else { + MergeDiscoveredGameDisc(&existing_game, game); + } + ++it; + continue; + } + title_id_to_index[game.title_id] = games.size(); + } + games.push_back(std::move(game)); + } + } + + ++it; + } + } + + for (auto& game : games) { + if (!game.title_id) { + continue; + } + std::error_code ec; + if (std::filesystem::exists(xe_title_content_root(game.title_id), ec)) { + game.has_installed_content = true; + } + } + + SortDiscoveredGames(&games); + return games; +} + +void ApplyIOSCompatibilityDataToDiscoveredGames( + NSDictionary* compat_data, std::vector* games) { + if (!games) { + return; + } + for (auto& game : *games) { + game.has_compat_info = false; + game.compat_status.clear(); + game.compat_perf.clear(); + game.compat_notes.clear(); + if (!compat_data || !game.title_id) { + continue; + } + NSString* key = XEFormatTitleIDHexUpper(game.title_id); + NSDictionary* info = [compat_data objectForKey:key]; + if (!info) { + continue; + } + NSString* title = xe_string_from_object(info[@"title"]); + if (title.length > 0) { + game.title = NormalizeGameTitleForUI(std::string([title UTF8String])); + } + NSDictionary* summary = xe_preferred_summary_from_compat_info(info); + NSDictionary* source = summary ?: info; + NSString* status = xe_string_from_object(source[@"status"]); + NSString* perf = xe_string_from_object(source[@"perf"]); + NSString* notes = xe_string_from_object(source[@"notes"]); + if ([status isKindOfClass:[NSString class]] && status.length > 0) { + game.has_compat_info = true; + game.compat_status = std::string([status UTF8String]); + game.compat_perf = + [perf isKindOfClass:[NSString class]] ? std::string([perf UTF8String]) : ""; + game.compat_notes = + [notes isKindOfClass:[NSString class]] ? std::string([notes UTF8String]) : ""; + } + } +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.h new file mode 100644 index 000000000..271787aa1 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.h @@ -0,0 +1,33 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_PATCHES_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_GAME_PATCHES_VIEW_CONTROLLER_H_ + +#import + +#include + +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace xe { +namespace ui { +class IOSWindowedAppContext; +} // namespace ui +} // namespace xe + +// Per-title patch toggle sheet backed by the same bundled patch database and +// storage override files used by the desktop UI. +@interface XeniaGamePatchesViewController : XESheetTableViewController +- (instancetype)initWithTitleID:(uint32_t)titleID + title:(NSString*)title + appContext:(xe::ui::IOSWindowedAppContext*)appContext; +@end + +#endif // XENIA_UI_IOS_GAME_PATCHES_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.mm new file mode 100644 index 000000000..8b9bea0c4 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_patches_view_controller.mm @@ -0,0 +1,228 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_patches_view_controller.h" + +#include +#include + +#import "xenia/ui/ios/app/windowed_app_context_ios.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +NSString* PatchDetailText(const xe::ui::IOSPatchEntrySummary& patch) { + NSMutableArray* parts = [NSMutableArray arrayWithCapacity:2]; + if (!patch.description.empty()) { + [parts addObject:ToNSString(patch.description)]; + } + if (!patch.author.empty()) { + [parts addObject:[NSString stringWithFormat:@"by %@", ToNSString(patch.author)]]; + } + return [parts componentsJoinedByString:@"\n"]; +} + +} // namespace + +@implementation XeniaGamePatchesViewController { + uint32_t title_id_; + NSString* game_title_; + xe::ui::IOSWindowedAppContext* app_context_; + std::vector patch_files_; + xe::ui::IOSPatchDiscoverySummary discovery_summary_; +} + +- (instancetype)initWithTitleID:(uint32_t)titleID + title:(NSString*)title + appContext:(xe::ui::IOSWindowedAppContext*)appContext { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + title_id_ = titleID; + game_title_ = [title copy]; + app_context_ = appContext; + self.title = @"Patches"; + } + return self; +} + +- (void)dealloc { + [game_title_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 76.0; + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithTitle:@"Close" + style:UIBarButtonItemStylePlain + target:self + action:@selector(doneTapped:)] autorelease]; + [self reloadPatches]; +} + +- (void)doneTapped:(id)__unused sender { + if (self.navigationController.presentingViewController && + self.navigationController.viewControllers.firstObject == self) { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; + return; + } + [self.navigationController popViewControllerAnimated:YES]; +} + +- (void)reloadPatches { + patch_files_ = app_context_ ? app_context_->ListPatchFiles(title_id_) + : std::vector(); + discovery_summary_ = app_context_ ? app_context_->GetPatchDiscoverySummary(title_id_) + : xe::ui::IOSPatchDiscoverySummary(); + [self.tableView reloadData]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return patch_files_.empty() ? 1 : static_cast(patch_files_.size()); +} + +- (NSInteger)tableView:(UITableView* __unused)tableView numberOfRowsInSection:(NSInteger)section { + if (patch_files_.empty()) { + return 1; + } + const auto& file = patch_files_[static_cast(section)]; + return file.patches.empty() ? 1 : static_cast(file.patches.size()); +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForHeaderInSection:(NSInteger)section { + if (patch_files_.empty()) { + return game_title_.length ? game_title_ : @"Game"; + } + const auto& file = patch_files_[static_cast(section)]; + if (!file.title_name.empty()) { + return ToNSString(file.title_name); + } + if (!file.display_name.empty()) { + return ToNSString(file.display_name); + } + return ToNSString(file.filename); +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForFooterInSection:(NSInteger)section { + if (patch_files_.empty()) { + if (!discovery_summary_.directory_exists) { + return @"The patches folder was not found."; + } + if (discovery_summary_.candidate_files == 0 && discovery_summary_.bundled_files == 0) { + return @"The patches folder did not contain any .patch.toml files."; + } + return @"Patch files were found, but none matched this title ID."; + } + if (section == static_cast(patch_files_.size()) - 1) { + return @"Patch changes are saved immediately and take effect the next time " + @"the game launches."; + } + return nil; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kPatchCellIdentifier = @"XeniaGamePatchCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kPatchCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kPatchCellIdentifier] autorelease]; + cell.textLabel.numberOfLines = 0; + cell.detailTextLabel.numberOfLines = 0; + } + + if (patch_files_.empty()) { + cell.textLabel.text = @"No patches available"; + if (!discovery_summary_.directory_exists) { + cell.detailTextLabel.text = + [NSString stringWithFormat:@"Create a patches folder here:\n%@", + ToNSString(discovery_summary_.directory_path)]; + } else if (discovery_summary_.candidate_files == 0 && discovery_summary_.bundled_files == 0) { + cell.detailTextLabel.text = + [NSString stringWithFormat:@"Scanned %zu files in:\n%@\n\nAdd xenia-canary " + @".patch.toml files for this title.", + discovery_summary_.scanned_files, + ToNSString(discovery_summary_.directory_path)]; + } else { + cell.detailTextLabel.text = [NSString + stringWithFormat:@"Title ID %08X did not match any discovered patch " + @"file.\n\nFolder: %@\nPatch-looking files: %zu\n" + @"Parse failures: %zu\nOther title IDs: %zu", + title_id_, ToNSString(discovery_summary_.directory_path), + discovery_summary_.candidate_files, discovery_summary_.parse_failures, + discovery_summary_.title_mismatches]; + } + cell.textLabel.textColor = [XeniaTheme textMuted]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + const auto& file = patch_files_[static_cast(indexPath.section)]; + if (file.patches.empty()) { + cell.textLabel.text = @"No patch entries"; + cell.detailTextLabel.text = @"This patch file did not contain any toggles."; + cell.textLabel.textColor = [XeniaTheme textMuted]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + const auto& patch = file.patches[static_cast(indexPath.row)]; + cell.textLabel.text = patch.name.empty() + ? [NSString stringWithFormat:@"Patch %zu", patch.patch_index + 1] + : ToNSString(patch.name); + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = PatchDetailText(patch); + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + UISwitch* toggle = [[[UISwitch alloc] init] autorelease]; + toggle.on = patch.is_enabled; + toggle.tag = indexPath.section * 1000 + indexPath.row; + [toggle addTarget:self + action:@selector(patchSwitchChanged:) + forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = toggle; + cell.accessibilityLabel = cell.textLabel.text; + cell.accessibilityValue = toggle.on ? @"Enabled" : @"Disabled"; + cell.accessibilityHint = @"Toggles this patch for the next launch."; + return cell; +} + +- (void)patchSwitchChanged:(UISwitch*)sender { + NSInteger section = sender.tag / 1000; + NSInteger row = sender.tag % 1000; + if (section < 0 || row < 0 || section >= static_cast(patch_files_.size())) { + return; + } + auto& file = patch_files_[static_cast(section)]; + if (row >= static_cast(file.patches.size())) { + return; + } + const auto& patch = file.patches[static_cast(row)]; + std::string status; + bool success = + app_context_ && app_context_->SetPatchEnabled(title_id_, file.filename, patch.patch_index, + sender.on, &status); + if (!success) { + sender.on = !sender.on; + XEPresentOKAlert(self, @"Patch Not Saved", + status.empty() ? @"Failed to update patch state." : ToNSString(status)); + return; + } + [self reloadPatches]; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.h b/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.h new file mode 100644 index 000000000..540df0a45 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.h @@ -0,0 +1,47 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_IOS_GAME_PICKER_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_LAUNCHER_IOS_GAME_PICKER_VIEW_CONTROLLER_H_ + +#ifdef __OBJC__ + +#import + +#include + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +@interface XeniaIOSGamePickerItem : NSObject + +@property(nonatomic, copy) NSString* title; +@property(nonatomic, copy) NSString* subtitle; +@property(nonatomic, assign) uint32_t titleID; +@property(nonatomic, assign) NSUInteger gameIndex; + ++ (instancetype)itemWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + titleID:(uint32_t)titleID + gameIndex:(NSUInteger)gameIndex; + +@end + +@interface XeniaIOSGamePickerViewController : XESheetTableViewController + +@property(nonatomic, copy) void (^selectionHandler)(NSUInteger gameIndex); + +- (instancetype)initWithTitle:(NSString*)title + prompt:(NSString*)prompt + items:(NSArray*)items; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_LAUNCHER_IOS_GAME_PICKER_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.mm b/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.mm new file mode 100644 index 000000000..2156cd185 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_picker_view_controller.mm @@ -0,0 +1,126 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_picker_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaIOSGamePickerItem + ++ (instancetype)itemWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + titleID:(uint32_t)titleID + gameIndex:(NSUInteger)gameIndex { + XeniaIOSGamePickerItem* item = [[[XeniaIOSGamePickerItem alloc] init] autorelease]; + item.title = title; + item.subtitle = subtitle; + item.titleID = titleID; + item.gameIndex = gameIndex; + return item; +} + +- (void)dealloc { + [_title release]; + [_subtitle release]; + [super dealloc]; +} + +@end + +@implementation XeniaIOSGamePickerViewController { + NSString* title_; + NSString* prompt_; + NSArray* items_; + void (^selection_handler_)(NSUInteger gameIndex); +} + +@synthesize selectionHandler = selection_handler_; + +- (instancetype)initWithTitle:(NSString*)title + prompt:(NSString*)prompt + items:(NSArray*)items { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + title_ = [title copy]; + prompt_ = [prompt copy]; + items_ = [items copy]; + } + return self; +} + +- (void)dealloc { + [title_ release]; + [prompt_ release]; + [items_ release]; + [selection_handler_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = title_; + self.navigationItem.prompt = prompt_; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 72.0; + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancelTapped:)] autorelease]; +} + +- (void)cancelTapped:(id)sender { + (void)sender; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)section { + return section == 0 ? static_cast(items_.count) : 0; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaIOSGamePickerCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + cell.backgroundColor = [UIColor secondarySystemGroupedBackgroundColor]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.textLabel.numberOfLines = 2; + cell.detailTextLabel.numberOfLines = 2; + } + + XeniaIOSGamePickerItem* item = [items_ objectAtIndex:indexPath.row]; + cell.textLabel.text = item.title; + cell.detailTextLabel.text = item.subtitle; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.imageView.image = [UIImage systemImageNamed:@"gamecontroller.fill"]; + cell.imageView.tintColor = [XeniaTheme accent]; + XEApplyAccessibility(cell, item.title, item.subtitle, @"Opens this game's touch layout editor.", + UIAccessibilityTraitButton); + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (!selection_handler_) { + return; + } + XeniaIOSGamePickerItem* item = [items_ objectAtIndex:indexPath.row]; + selection_handler_(item.gameIndex); +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_game_tile_cell.h b/src/xenia/ui/ios/launcher/ios_game_tile_cell.h new file mode 100644 index 000000000..d5e875893 --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_tile_cell.h @@ -0,0 +1,29 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_GAME_TILE_CELL_H_ +#define XENIA_UI_IOS_GAME_TILE_CELL_H_ + +#import + +#include "xenia/ui/ios/shared/ios_theme.h" + +// Collection-view cell for the launcher's library: card with cover artwork, +// title and optional compatibility pill. Highlights when controller focus +// lands on it. +@interface XeniaGameTileCell : UICollectionViewCell +@property(nonatomic, strong) UIView* cardView; +@property(nonatomic, strong) UIImageView* iconView; +@property(nonatomic, strong) UILabel* titleLabel; +@property(nonatomic, strong) XeniaPaddedLabel* contentTypePill; +@property(nonatomic, strong) XeniaPaddedLabel* compatPill; +@property(nonatomic, assign) BOOL controllerFocused; +@end + +#endif // XENIA_UI_IOS_GAME_TILE_CELL_H_ diff --git a/src/xenia/ui/ios/launcher/ios_game_tile_cell.mm b/src/xenia/ui/ios/launcher/ios_game_tile_cell.mm new file mode 100644 index 000000000..3140f6a7b --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_game_tile_cell.mm @@ -0,0 +1,238 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_game_tile_cell.h" + +@interface XeniaGameTileCell () +@property(nonatomic, strong) UIView* footerView; +@property(nonatomic, strong) UIStackView* metadataStackView; +@end + +@implementation XeniaGameTileCell + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (!self) { + return nil; + } + + self.backgroundColor = [UIColor clearColor]; + self.layer.cornerRadius = XeniaRadiusLg; + self.layer.masksToBounds = NO; + self.layer.shadowOffset = CGSizeMake(0.0f, 6.0f); + self.contentView.backgroundColor = [UIColor clearColor]; + self.isAccessibilityElement = YES; + self.accessibilityTraits = UIAccessibilityTraitButton; + + self.cardView = [[UIView alloc] init]; + self.cardView.translatesAutoresizingMaskIntoConstraints = NO; + self.cardView.backgroundColor = [XeniaTheme bgSurface]; + self.cardView.layer.cornerRadius = XeniaRadiusLg; + self.cardView.layer.borderWidth = 0.5; + self.cardView.layer.borderColor = [XeniaTheme border].CGColor; + self.cardView.clipsToBounds = YES; + [self.contentView addSubview:self.cardView]; + + self.iconView = [[UIImageView alloc] init]; + self.iconView.translatesAutoresizingMaskIntoConstraints = NO; + self.iconView.contentMode = UIViewContentModeScaleAspectFill; + self.iconView.clipsToBounds = YES; + self.iconView.backgroundColor = [XeniaTheme bgSurface2]; + [self.cardView addSubview:self.iconView]; + + self.footerView = [[UIView alloc] init]; + self.footerView.translatesAutoresizingMaskIntoConstraints = NO; + self.footerView.backgroundColor = [XeniaTheme bgSurface]; + [self.cardView addSubview:self.footerView]; + + self.contentTypePill = [[XeniaPaddedLabel alloc] init]; + self.contentTypePill.translatesAutoresizingMaskIntoConstraints = NO; + self.contentTypePill.padding = UIEdgeInsetsZero; + self.contentTypePill.textAlignment = NSTextAlignmentLeft; + self.contentTypePill.layer.cornerRadius = 0.0; + self.contentTypePill.layer.borderWidth = 0.0; + self.contentTypePill.clipsToBounds = NO; + xe_apply_label_font(self.contentTypePill, UIFontTextStyleCaption2, 10.0, UIFontWeightSemibold); + self.contentTypePill.textColor = [XeniaTheme textMuted]; + self.contentTypePill.backgroundColor = [UIColor clearColor]; + self.contentTypePill.hidden = YES; + [self.contentTypePill setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [self.contentTypePill setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + xe_apply_label_font(self.titleLabel, UIFontTextStyleSubheadline, 15.0, UIFontWeightSemibold); + self.titleLabel.textColor = [XeniaTheme textPrimary]; + self.titleLabel.textAlignment = NSTextAlignmentLeft; + self.titleLabel.numberOfLines = 1; + self.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + self.titleLabel.allowsDefaultTighteningForTruncation = YES; + self.titleLabel.adjustsFontSizeToFitWidth = NO; + [self.titleLabel setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [self.titleLabel setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisVertical]; + [self.footerView addSubview:self.titleLabel]; + + self.compatPill = [[XeniaPaddedLabel alloc] init]; + self.compatPill.translatesAutoresizingMaskIntoConstraints = NO; + self.compatPill.padding = UIEdgeInsetsZero; + self.compatPill.textAlignment = NSTextAlignmentLeft; + self.compatPill.layer.cornerRadius = 0.0; + self.compatPill.layer.borderWidth = 0.0; + self.compatPill.clipsToBounds = NO; + self.compatPill.backgroundColor = [UIColor clearColor]; + xe_apply_label_font(self.compatPill, UIFontTextStyleCaption2, 10.0, UIFontWeightSemibold); + self.compatPill.hidden = YES; + [self.compatPill setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [self.compatPill setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + + self.metadataStackView = + [[UIStackView alloc] initWithArrangedSubviews:@[ self.contentTypePill, self.compatPill ]]; + self.metadataStackView.translatesAutoresizingMaskIntoConstraints = NO; + self.metadataStackView.axis = UILayoutConstraintAxisHorizontal; + self.metadataStackView.alignment = UIStackViewAlignmentCenter; + self.metadataStackView.distribution = UIStackViewDistributionFill; + self.metadataStackView.spacing = 8.0; + [self.metadataStackView setContentCompressionResistancePriority:UILayoutPriorityDefaultLow + forAxis:UILayoutConstraintAxisHorizontal]; + [self.metadataStackView setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisVertical]; + [self.footerView addSubview:self.metadataStackView]; + + // Apply the default chrome (shadow + border + label color) up front. + // -updateControllerFocusAppearance only runs from -setControllerFocused: + // and -traitCollectionDidChange:, so without this call a freshly-created + // (never-focused) cell inherits the layer-default shadowOpacity = 0 and + // never picks up the always-on tile shadow until it gets focused once. + [self updateControllerFocusAppearance]; + + [NSLayoutConstraint activateConstraints:@[ + [self.cardView.topAnchor constraintEqualToAnchor:self.contentView.topAnchor], + [self.cardView.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor], + [self.cardView.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor], + [self.cardView.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor], + [self.iconView.topAnchor constraintEqualToAnchor:self.cardView.topAnchor], + [self.iconView.leadingAnchor constraintEqualToAnchor:self.cardView.leadingAnchor], + [self.iconView.trailingAnchor constraintEqualToAnchor:self.cardView.trailingAnchor], + [self.iconView.heightAnchor constraintEqualToAnchor:self.iconView.widthAnchor + multiplier:300.0 / 219.0], + [self.footerView.topAnchor constraintEqualToAnchor:self.iconView.bottomAnchor], + [self.footerView.leadingAnchor constraintEqualToAnchor:self.cardView.leadingAnchor], + [self.footerView.trailingAnchor constraintEqualToAnchor:self.cardView.trailingAnchor], + [self.footerView.bottomAnchor constraintEqualToAnchor:self.cardView.bottomAnchor], + [self.titleLabel.topAnchor constraintEqualToAnchor:self.footerView.topAnchor constant:8.0], + [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.footerView.leadingAnchor + constant:10.0], + [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.footerView.trailingAnchor + constant:-10.0], + [self.titleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.metadataStackView.topAnchor + constant:-2.0], + [self.metadataStackView.leadingAnchor constraintEqualToAnchor:self.titleLabel.leadingAnchor], + [self.metadataStackView.trailingAnchor + constraintLessThanOrEqualToAnchor:self.titleLabel.trailingAnchor], + [self.metadataStackView.bottomAnchor constraintEqualToAnchor:self.footerView.bottomAnchor + constant:-8.0], + ]]; + + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.layer.shadowPath = + [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:XeniaRadiusLg].CGPath; + self.titleLabel.preferredMaxLayoutWidth = MAX(CGRectGetWidth(self.footerView.bounds) - 20.0, 0.0); +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if ([self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + // CGColor is captured at assignment time and won't auto-flip. Re-apply + // the focus-aware chrome from the dynamic XeniaTheme accessors so it + // tracks the new userInterfaceStyle. + [self updateControllerFocusAppearance]; + } +} + +- (void)prepareForReuse { + [super prepareForReuse]; + self.iconView.image = nil; + self.titleLabel.text = @""; + self.contentTypePill.text = @""; + self.contentTypePill.hidden = YES; + self.contentTypePill.backgroundColor = [UIColor clearColor]; + self.contentTypePill.layer.borderColor = [UIColor clearColor].CGColor; + self.compatPill.text = @""; + self.compatPill.hidden = YES; + self.compatPill.backgroundColor = [UIColor clearColor]; + self.compatPill.layer.borderColor = [UIColor clearColor].CGColor; + self.transform = CGAffineTransformIdentity; + self.contentView.transform = CGAffineTransformIdentity; + self.accessibilityLabel = nil; + self.accessibilityValue = nil; + _controllerFocused = NO; + // Re-run the appearance setter explicitly: setting _controllerFocused + // directly above bypassed -setControllerFocused: (since the previous + // value may already have been NO if the cell was never focused), so the + // shadow/border state has to be reset by hand to keep recycled cells + // looking identical to fresh ones. + [self updateControllerFocusAppearance]; +} + +- (void)setControllerFocused:(BOOL)controllerFocused { + if (_controllerFocused == controllerFocused) { + return; + } + _controllerFocused = controllerFocused; + [self updateControllerFocusAppearance]; +} + +- (void)updateControllerFocusAppearance { + self.footerView.backgroundColor = [XeniaTheme bgSurface]; + if (self.controllerFocused) { + self.cardView.layer.borderWidth = 1.5f; + self.cardView.layer.borderColor = [XeniaTheme accent].CGColor; + self.titleLabel.textColor = [XeniaTheme textPrimary]; + self.layer.shadowColor = [XeniaTheme accent].CGColor; + self.layer.shadowOpacity = 0.24f; + self.layer.shadowRadius = 10.0f; + self.layer.zPosition = 1.0f; + } else { + self.cardView.layer.borderWidth = 0.5f; + self.cardView.layer.borderColor = [XeniaTheme border].CGColor; + self.titleLabel.textColor = [XeniaTheme textPrimary]; + // A subtle always-on shadow so light-mode tiles read as floating cards + // against a near-white background. In dark mode the shadow is black-on- + // black at 18% opacity, which still gives a soft separation without + // looking out of place. + self.layer.shadowColor = + [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return traits.userInterfaceStyle == UIUserInterfaceStyleLight + ? [[UIColor blackColor] colorWithAlphaComponent:0.18] + : [[UIColor blackColor] colorWithAlphaComponent:0.42]; + }].CGColor; + self.layer.shadowOpacity = 0.18f; + self.layer.shadowRadius = 6.0f; + self.layer.zPosition = 0.0f; + } + self.contentTypePill.textColor = [XeniaTheme textMuted]; + self.contentTypePill.backgroundColor = [UIColor clearColor]; + self.contentTypePill.layer.borderColor = [UIColor clearColor].CGColor; + self.compatPill.backgroundColor = [UIColor clearColor]; + self.compatPill.layer.borderWidth = 0.0f; + self.compatPill.layer.borderColor = [UIColor clearColor].CGColor; +} + +@end diff --git a/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.h b/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.h new file mode 100644 index 000000000..1127b6d9d --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.h @@ -0,0 +1,89 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LAUNCHER_OVERLAY_VIEW_H_ +#define XENIA_UI_IOS_LAUNCHER_OVERLAY_VIEW_H_ + +#ifdef __OBJC__ + +#import + +// Lightweight snapshot the root controller builds from its IOSDiscoveredGame +// vector. The launcher view never imports files or launches games directly. +@interface XeniaIOSLauncherGameSnapshot : NSObject +@property(nonatomic, copy) NSString* title; +@property(nonatomic, copy) NSString* contentTypeName; +@property(nonatomic, copy) NSString* compatStatus; +@property(nonatomic, assign) BOOL hasCompatInfo; +@property(nonatomic, assign) uint32_t titleId; +@property(nonatomic, assign) BOOL supportsCompatibility; +@property(nonatomic, assign) BOOL supportsManageContent; +@property(nonatomic, assign) BOOL supportsDiscSelection; +@property(nonatomic, assign) BOOL supportsPatches; +@property(nonatomic, assign) BOOL supportsRemoteArt; +@property(nonatomic, assign) NSUInteger discCount; +@property(nonatomic, strong) NSData* iconData; +@end + +// Replaces the launcher overlay previously constructed inline in +// XeniaViewController. Owns the nav bar, JIT status indicators, +// game collection view, and status label. Communicates with the root +// controller exclusively through callback blocks and typed data snapshots. +@interface XeniaIOSLauncherOverlayView : UIView + +#pragma mark - Callbacks (set by the root controller) + +@property(nonatomic, copy) void (^settingsHandler)(void); +@property(nonatomic, copy) void (^profileHandler)(void); +@property(nonatomic, copy) void (^importHandler)(void); +@property(nonatomic, copy) void (^gameLaunchedHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^copyLaunchURLHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^gameSettingsHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^touchLayoutHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^compatibilityHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^manageContentHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^discSelectionHandler)(NSUInteger gameIndex); +@property(nonatomic, copy) void (^patchesHandler)(NSUInteger gameIndex); + +#pragma mark - Data ingestion + +- (void)setGames:(NSArray*)games; +- (void)setJITAcquired:(BOOL)acquired; +- (void)setJITStatusText:(NSString*)text; +- (void)setFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll; +- (NSInteger)focusedGameIndex; +- (void)reloadGames; +@property(nonatomic, assign, getter=isActionsEnabled) BOOL actionsEnabled; + +#pragma mark - Focus visual (called by root when controller nav state changes) + +- (void)setControllerNavigationEnabled:(BOOL)enabled + settingsFocused:(BOOL)settingsFocused + profileFocused:(BOOL)profileFocused + importFocused:(BOOL)importFocused + libraryFocusActive:(BOOL)libraryFocusActive; + +#pragma mark - Read-only accessors for root layout/state queries + +@property(nonatomic, readonly) UILabel* statusLabel; +@property(nonatomic, readonly) UICollectionView* gamesCollectionView; +@property(nonatomic, readonly) BOOL isOverlayVisible; +- (NSInteger)columnCount; +- (NSInteger)pageStep; +- (void)refreshChromeForCurrentTraits; + +#pragma mark - Visibility + +- (void)setOverlayVisible:(BOOL)visible animated:(BOOL)animated; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_LAUNCHER_OVERLAY_VIEW_H_ diff --git a/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.mm b/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.mm new file mode 100644 index 000000000..a0f19c15b --- /dev/null +++ b/src/xenia/ui/ios/launcher/ios_launcher_overlay_view.mm @@ -0,0 +1,1061 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/launcher/ios_launcher_overlay_view.h" + +#include + +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/launcher/ios_game_art.h" +#import "xenia/ui/ios/launcher/ios_game_tile_cell.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +@implementation XeniaIOSLauncherGameSnapshot + +- (void)dealloc { + [_title release]; + [_contentTypeName release]; + [_compatStatus release]; + [_iconData release]; + [super dealloc]; +} + +@end + +@interface XeniaIOSLauncherOverlayView () +@property(nonatomic, strong) NSArray* games; +@property(nonatomic, assign) BOOL jitAcquired; +@end + +@implementation XeniaIOSLauncherOverlayView { + UIView* _navSep; + UILabel* _titleLabel; + UIButton* _settingsButton; + UIButton* _profileButton; + UIButton* _openGameButton; + UIView* _jitWarningCard; + UIView* _jitStatusDot; + UIView* _jitStatusRing; + UILabel* _jitStatusLabel; + UIView* _jitReadyDot; + UIView* _jitReadyRing; + UILabel* _jitReadyLabel; + UILabel* _libraryLabel; + UIStackView* _topInfoStack; + UILabel* _emptyLabel; + UILabel* _statusLabel; + UICollectionView* _gamesCollectionView; + NSInteger _focusedGameIndex; + BOOL _actionsEnabled; + BOOL _controllerNavigationEnabled; + BOOL _libraryFocusActive; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (!(self = [super initWithFrame:frame])) { + return nil; + } + _focusedGameIndex = -1; + _jitAcquired = NO; + _actionsEnabled = YES; + _controllerNavigationEnabled = NO; + _libraryFocusActive = NO; + + self.backgroundColor = [XeniaTheme bgPrimary]; + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + UILayoutGuide* safe = self.safeAreaLayoutGuide; + CGFloat hPad = 16.0; + + // ── Nav bar: XeniOS (left) · gear + profile (right) ──────────────────── + + _titleLabel = [[UILabel alloc] init]; + { + UIFont* titleFont = xe_scaled_system_font(UIFontTextStyleTitle2, 22.0, UIFontWeightBold); + NSDictionary* whiteAttrs = @{ + NSFontAttributeName : titleFont, + NSForegroundColorAttributeName : [XeniaTheme textPrimary], + NSKernAttributeName : @(0.8), + }; + NSDictionary* accentAttrs = @{ + NSFontAttributeName : titleFont, + NSForegroundColorAttributeName : [XeniaTheme accent], + NSKernAttributeName : @(0.8), + }; + NSMutableAttributedString* title = + [[NSMutableAttributedString alloc] initWithString:@"Xeni" attributes:whiteAttrs]; + NSAttributedString* titleSuffix = [[NSAttributedString alloc] initWithString:@"OS" + attributes:accentAttrs]; + [title appendAttributedString:titleSuffix]; + [titleSuffix release]; + _titleLabel.attributedText = title; + [title release]; + } + _titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _titleLabel.adjustsFontForContentSizeCategory = YES; + [self addSubview:_titleLabel]; + + UIButtonConfiguration* settingsCfg = [UIButtonConfiguration plainButtonConfiguration]; + settingsCfg.image = + [UIImage systemImageNamed:@"gearshape" + withConfiguration:[UIImageSymbolConfiguration + configurationWithPointSize:20 + weight:UIImageSymbolWeightRegular]]; + settingsCfg.baseForegroundColor = [XeniaTheme textMuted]; + settingsCfg.contentInsets = NSDirectionalEdgeInsetsMake(8, 8, 8, 8); + _settingsButton = [[UIButton buttonWithConfiguration:settingsCfg primaryAction:nil] retain]; + _settingsButton.translatesAutoresizingMaskIntoConstraints = NO; + [_settingsButton addTarget:self + action:@selector(settingsTapped:) + forControlEvents:UIControlEventTouchUpInside]; + XEApplyAccessibility(_settingsButton, @"Settings", nil, @"Opens XeniOS settings.", + UIAccessibilityTraitButton); + [self addSubview:_settingsButton]; + + UIButtonConfiguration* profileCfg = [UIButtonConfiguration plainButtonConfiguration]; + profileCfg.image = + [UIImage systemImageNamed:@"person.circle" + withConfiguration:[UIImageSymbolConfiguration + configurationWithPointSize:20 + weight:UIImageSymbolWeightRegular]]; + profileCfg.baseForegroundColor = [XeniaTheme textMuted]; + profileCfg.contentInsets = NSDirectionalEdgeInsetsMake(8, 8, 8, 8); + _profileButton = [[UIButton buttonWithConfiguration:profileCfg primaryAction:nil] retain]; + _profileButton.translatesAutoresizingMaskIntoConstraints = NO; + [_profileButton addTarget:self + action:@selector(profileTapped:) + forControlEvents:UIControlEventTouchUpInside]; + XEApplyAccessibility(_profileButton, @"Profile", nil, @"Opens profile management.", + UIAccessibilityTraitButton); + [self addSubview:_profileButton]; + + // JIT ready indicator in nav bar. + _jitReadyRing = [[UIView alloc] init]; + _jitReadyRing.translatesAutoresizingMaskIntoConstraints = NO; + _jitReadyRing.backgroundColor = [UIColor clearColor]; + _jitReadyRing.layer.cornerRadius = 7.0; // 14pt diameter / 2 — circular indicator. + _jitReadyRing.layer.borderWidth = 1.25; + _jitReadyRing.layer.borderColor = [XeniaTheme accent].CGColor; + _jitReadyRing.alpha = 0; + _jitReadyRing.userInteractionEnabled = NO; + [self addSubview:_jitReadyRing]; + + _jitReadyDot = [[UIView alloc] init]; + _jitReadyDot.translatesAutoresizingMaskIntoConstraints = NO; + _jitReadyDot.backgroundColor = [XeniaTheme accent]; + _jitReadyDot.layer.cornerRadius = 4.0; // 8pt diameter / 2 — circular dot. + [self addSubview:_jitReadyDot]; + + _jitReadyLabel = [[UILabel alloc] init]; + _jitReadyLabel.translatesAutoresizingMaskIntoConstraints = NO; + _jitReadyLabel.text = @"JIT Enabled"; + _jitReadyLabel.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(_jitReadyLabel, UIFontTextStyleCaption2, 11.0, UIFontWeightSemibold); + [self addSubview:_jitReadyLabel]; + + // Thin separator below the nav bar. + _navSep = [[UIView alloc] init]; + _navSep.translatesAutoresizingMaskIntoConstraints = NO; + _navSep.backgroundColor = [XeniaTheme border]; + [self addSubview:_navSep]; + + // ── Compact JIT notice row shown only when JIT is not enabled ────────── + + _jitWarningCard = [[UIView alloc] init]; + _jitWarningCard.translatesAutoresizingMaskIntoConstraints = NO; + _jitWarningCard.backgroundColor = [XeniaTheme bgSurface]; + _jitWarningCard.layer.cornerRadius = XeniaRadiusLg; + _jitWarningCard.layer.borderWidth = 0.5; + _jitWarningCard.layer.borderColor = [XeniaTheme border].CGColor; + + _jitStatusRing = [[UIView alloc] init]; + _jitStatusRing.translatesAutoresizingMaskIntoConstraints = NO; + _jitStatusRing.backgroundColor = [UIColor clearColor]; + _jitStatusRing.layer.cornerRadius = 6.0; // 12pt diameter / 2 — circular indicator. + _jitStatusRing.layer.borderWidth = 1.25; + _jitStatusRing.layer.borderColor = [XeniaTheme statusError].CGColor; + _jitStatusRing.alpha = 0; + _jitStatusRing.userInteractionEnabled = NO; + [_jitWarningCard addSubview:_jitStatusRing]; + + _jitStatusDot = [[UIView alloc] init]; + _jitStatusDot.translatesAutoresizingMaskIntoConstraints = NO; + _jitStatusDot.backgroundColor = [XeniaTheme statusError]; + _jitStatusDot.layer.cornerRadius = 3.5; // 7pt diameter / 2 — circular dot. + [_jitWarningCard addSubview:_jitStatusDot]; + + _jitStatusLabel = [[UILabel alloc] init]; + _jitStatusLabel.translatesAutoresizingMaskIntoConstraints = NO; + _jitStatusLabel.text = @"Waiting for JIT..."; + _jitStatusLabel.textColor = [XeniaTheme textPrimary]; + xe_apply_label_font(_jitStatusLabel, UIFontTextStyleFootnote, 13.0, UIFontWeightMedium); + _jitStatusLabel.numberOfLines = 0; + [_jitWarningCard addSubview:_jitStatusLabel]; + XEApplyAccessibility(_jitWarningCard, @"JIT status", _jitStatusLabel.text, nil, + UIAccessibilityTraitStaticText); + + // ── Library header: "Library" (left) + "+" (right) ───────────────────── + + UIView* libraryRow = [[UIView alloc] init]; + libraryRow.translatesAutoresizingMaskIntoConstraints = NO; + + _libraryLabel = [[UILabel alloc] init]; + _libraryLabel.translatesAutoresizingMaskIntoConstraints = NO; + _libraryLabel.text = @"Library"; + _libraryLabel.textColor = [XeniaTheme textPrimary]; + xe_apply_label_font(_libraryLabel, UIFontTextStyleTitle3, 20.0, UIFontWeightSemibold); + _libraryLabel.accessibilityTraits = UIAccessibilityTraitHeader; + [libraryRow addSubview:_libraryLabel]; + + UIButtonConfiguration* importCfg = [UIButtonConfiguration plainButtonConfiguration]; + importCfg.image = + [UIImage systemImageNamed:@"plus" + withConfiguration:[UIImageSymbolConfiguration + configurationWithPointSize:20 + weight:UIImageSymbolWeightMedium]]; + importCfg.baseForegroundColor = [XeniaTheme accent]; + importCfg.contentInsets = NSDirectionalEdgeInsetsMake(6, 6, 6, 6); + _openGameButton = [[UIButton buttonWithConfiguration:importCfg primaryAction:nil] retain]; + _openGameButton.translatesAutoresizingMaskIntoConstraints = NO; + [_openGameButton addTarget:self + action:@selector(importTapped:) + forControlEvents:UIControlEventTouchUpInside]; + XEApplyAccessibility(_openGameButton, @"Import game", nil, + @"Opens the document picker to import a game.", UIAccessibilityTraitButton); + [libraryRow addSubview:_openGameButton]; + + // ── Collapsible stack: JIT banner → library header ───────────────────── + + _topInfoStack = [[UIStackView alloc] initWithArrangedSubviews:@[ _jitWarningCard, libraryRow ]]; + _topInfoStack.axis = UILayoutConstraintAxisVertical; + _topInfoStack.spacing = 12; + _topInfoStack.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_topInfoStack]; + + // ── Games grid ───────────────────────────────────────────────────────── + + UICollectionViewFlowLayout* gridLayout = [[UICollectionViewFlowLayout alloc] init]; + gridLayout.minimumInteritemSpacing = 16; + gridLayout.minimumLineSpacing = 20; + gridLayout.sectionInset = UIEdgeInsetsZero; + _gamesCollectionView = [[UICollectionView alloc] initWithFrame:CGRectZero + collectionViewLayout:gridLayout]; + _gamesCollectionView.translatesAutoresizingMaskIntoConstraints = NO; + _gamesCollectionView.dataSource = self; + _gamesCollectionView.delegate = self; + if (@available(iOS 11.0, *)) { + _gamesCollectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + _gamesCollectionView.backgroundColor = [UIColor clearColor]; + _gamesCollectionView.alwaysBounceVertical = YES; + [_gamesCollectionView registerClass:[XeniaGameTileCell class] + forCellWithReuseIdentifier:@"ImportedGameCell"]; + [self addSubview:_gamesCollectionView]; + + // Empty-state label. + UIView* emptyBg = [[UIView alloc] initWithFrame:CGRectZero]; + emptyBg.frame = _gamesCollectionView.bounds; + emptyBg.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _emptyLabel = [[UILabel alloc] init]; + _emptyLabel.translatesAutoresizingMaskIntoConstraints = NO; + _emptyLabel.text = + @"No games yet.\nTransfer ISO or GOD files to the\nDocuments folder, or tap +."; + _emptyLabel.textColor = [XeniaTheme textMuted]; + xe_apply_label_font(_emptyLabel, UIFontTextStyleSubheadline, 15.0, UIFontWeightRegular); + _emptyLabel.textAlignment = NSTextAlignmentCenter; + _emptyLabel.numberOfLines = 0; + [emptyBg addSubview:_emptyLabel]; + NSLayoutConstraint* emptyLabelLeading = + [_emptyLabel.leadingAnchor constraintGreaterThanOrEqualToAnchor:emptyBg.leadingAnchor + constant:32]; + emptyLabelLeading.priority = UILayoutPriorityDefaultHigh; + NSLayoutConstraint* emptyLabelTrailing = + [_emptyLabel.trailingAnchor constraintLessThanOrEqualToAnchor:emptyBg.trailingAnchor + constant:-32]; + emptyLabelTrailing.priority = UILayoutPriorityDefaultHigh; + [NSLayoutConstraint activateConstraints:@[ + [_emptyLabel.centerXAnchor constraintEqualToAnchor:emptyBg.centerXAnchor], + [_emptyLabel.centerYAnchor constraintEqualToAnchor:emptyBg.centerYAnchor], + emptyLabelLeading, + emptyLabelTrailing, + ]]; + _gamesCollectionView.backgroundView = emptyBg; + [emptyBg release]; + + // ── Status label (ephemeral, bottom overlay) ───────────────────────────── + + _statusLabel = [[UILabel alloc] init]; + _statusLabel.text = @""; + _statusLabel.textColor = [XeniaTheme textMuted]; + xe_apply_label_font(_statusLabel, UIFontTextStyleCaption2, 11.0, UIFontWeightRegular); + _statusLabel.textAlignment = NSTextAlignmentCenter; + _statusLabel.translatesAutoresizingMaskIntoConstraints = NO; + _statusLabel.hidden = YES; + _statusLabel.userInteractionEnabled = NO; + [self addSubview:_statusLabel]; + + // ── Layout ───────────────────────────────────────────────────────────── + + [NSLayoutConstraint activateConstraints:@[ + // Nav bar. + [_titleLabel.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:hPad], + [_titleLabel.topAnchor constraintEqualToAnchor:safe.topAnchor constant:6], + [_profileButton.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-8], + [_profileButton.centerYAnchor constraintEqualToAnchor:_titleLabel.centerYAnchor], + [_settingsButton.trailingAnchor constraintEqualToAnchor:_profileButton.leadingAnchor + constant:-2], + [_settingsButton.centerYAnchor constraintEqualToAnchor:_titleLabel.centerYAnchor], + // JIT ready dot + ring + label sit right after the title. + [_jitReadyDot.leadingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor constant:10], + [_jitReadyDot.centerYAnchor constraintEqualToAnchor:_titleLabel.centerYAnchor], + [_jitReadyDot.widthAnchor constraintEqualToConstant:8], + [_jitReadyDot.heightAnchor constraintEqualToConstant:8], + [_jitReadyRing.centerXAnchor constraintEqualToAnchor:_jitReadyDot.centerXAnchor], + [_jitReadyRing.centerYAnchor constraintEqualToAnchor:_jitReadyDot.centerYAnchor], + [_jitReadyRing.widthAnchor constraintEqualToConstant:14], + [_jitReadyRing.heightAnchor constraintEqualToConstant:14], + [_jitReadyLabel.leadingAnchor constraintEqualToAnchor:_jitReadyDot.trailingAnchor constant:6], + [_jitReadyLabel.centerYAnchor constraintEqualToAnchor:_titleLabel.centerYAnchor], + [_jitReadyLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_settingsButton.leadingAnchor + constant:-8], + + // Nav separator. + [_navSep.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:6], + [_navSep.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_navSep.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_navSep.heightAnchor constraintEqualToConstant:0.5], + + // Header stack (JIT banner + library row) below separator. + [_topInfoStack.topAnchor constraintEqualToAnchor:_navSep.bottomAnchor constant:12], + [_topInfoStack.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:hPad], + [_topInfoStack.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-hPad], + + // JIT banner internals. + [_jitStatusDot.leadingAnchor constraintEqualToAnchor:_jitWarningCard.leadingAnchor constant:14], + [_jitStatusDot.centerYAnchor constraintEqualToAnchor:_jitWarningCard.centerYAnchor], + [_jitStatusDot.widthAnchor constraintEqualToConstant:7], + [_jitStatusDot.heightAnchor constraintEqualToConstant:7], + [_jitStatusRing.centerXAnchor constraintEqualToAnchor:_jitStatusDot.centerXAnchor], + [_jitStatusRing.centerYAnchor constraintEqualToAnchor:_jitStatusDot.centerYAnchor], + [_jitStatusRing.widthAnchor constraintEqualToConstant:12], + [_jitStatusRing.heightAnchor constraintEqualToConstant:12], + [_jitStatusLabel.leadingAnchor constraintEqualToAnchor:_jitStatusDot.trailingAnchor constant:8], + [_jitStatusLabel.trailingAnchor constraintEqualToAnchor:_jitWarningCard.trailingAnchor + constant:-14], + [_jitStatusLabel.topAnchor constraintEqualToAnchor:_jitWarningCard.topAnchor constant:10], + [_jitStatusLabel.bottomAnchor constraintEqualToAnchor:_jitWarningCard.bottomAnchor + constant:-10], + + // Library row internals. + [_libraryLabel.leadingAnchor constraintEqualToAnchor:libraryRow.leadingAnchor], + [_libraryLabel.centerYAnchor constraintEqualToAnchor:libraryRow.centerYAnchor], + [_openGameButton.trailingAnchor constraintEqualToAnchor:libraryRow.trailingAnchor], + [_openGameButton.centerYAnchor constraintEqualToAnchor:libraryRow.centerYAnchor], + [libraryRow.heightAnchor constraintEqualToConstant:34], + + // Games grid. + [_gamesCollectionView.topAnchor constraintEqualToAnchor:_topInfoStack.bottomAnchor constant:8], + [_gamesCollectionView.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:hPad], + [_gamesCollectionView.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor + constant:-hPad], + [_gamesCollectionView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + + // Status label. + [_statusLabel.centerXAnchor constraintEqualToAnchor:safe.centerXAnchor], + [_statusLabel.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor constant:-6], + ]]; + + [gridLayout release]; + [libraryRow release]; + [self setJITAcquired:NO]; + [self applyActionsEnabledState]; + return self; +} + +- (void)dealloc { + [_games release]; + [_gamesCollectionView release]; + [_statusLabel release]; + [_settingsHandler release]; + [_profileHandler release]; + [_importHandler release]; + [_gameLaunchedHandler release]; + [_copyLaunchURLHandler release]; + [_gameSettingsHandler release]; + [_touchLayoutHandler release]; + [_compatibilityHandler release]; + [_manageContentHandler release]; + [_discSelectionHandler release]; + [_patchesHandler release]; + [_titleLabel release]; + [_settingsButton release]; + [_profileButton release]; + [_openGameButton release]; + [_jitWarningCard release]; + [_jitStatusDot release]; + [_jitStatusRing release]; + [_jitStatusLabel release]; + [_jitReadyDot release]; + [_jitReadyRing release]; + [_jitReadyLabel release]; + [_libraryLabel release]; + [_topInfoStack release]; + [_emptyLabel release]; + [_navSep release]; + [super dealloc]; +} + +#pragma mark - Button actions (forward to handlers) + +- (void)settingsTapped:(UIButton*)__unused sender { + if (_settingsHandler) { + _settingsHandler(); + } +} + +- (void)profileTapped:(UIButton*)__unused sender { + if (_profileHandler) { + _profileHandler(); + } +} + +- (void)importTapped:(UIButton*)__unused sender { + if (_importHandler) { + _importHandler(); + } +} + +#pragma mark - Focus visuals + +- (void)applyActionsEnabledState { + _settingsButton.enabled = _actionsEnabled; + _profileButton.enabled = _actionsEnabled; + _openGameButton.enabled = _actionsEnabled; + + CGFloat actionAlpha = _actionsEnabled ? 1.0 : 0.5; + _settingsButton.alpha = actionAlpha; + _profileButton.alpha = actionAlpha; + _openGameButton.alpha = actionAlpha; +} + +- (void)setActionsEnabled:(BOOL)actionsEnabled { + if (_actionsEnabled == actionsEnabled) { + return; + } + _actionsEnabled = actionsEnabled; + [self applyActionsEnabledState]; +} + +- (BOOL)isActionsEnabled { + return _actionsEnabled; +} + +- (void)setButton:(UIButton*)button controllerFocused:(BOOL)focused { + if (!button) { + return; + } + button.layer.cornerRadius = XeniaRadiusMd; + button.layer.borderWidth = focused ? 1.5 : 0.0; + button.layer.borderColor = focused ? [XeniaTheme accent].CGColor : [UIColor clearColor].CGColor; + button.layer.shadowColor = [XeniaTheme accent].CGColor; + button.layer.shadowOpacity = focused ? 0.35f : 0.0f; + button.layer.shadowRadius = focused ? 6.0f : 0.0f; + button.layer.shadowOffset = CGSizeZero; +} + +- (void)clearButtonFocusChrome:(UIButton*)button { + if (!button) { + return; + } + button.layer.cornerRadius = XeniaRadiusMd; + button.layer.borderWidth = 0.0f; + button.layer.borderColor = [UIColor clearColor].CGColor; + button.layer.shadowOpacity = 0.0f; + button.layer.shadowRadius = 0.0f; + button.layer.shadowOffset = CGSizeZero; +} + +- (void)setControllerNavigationEnabled:(BOOL)enabled + settingsFocused:(BOOL)settingsFocused + profileFocused:(BOOL)profileFocused + importFocused:(BOOL)importFocused + libraryFocusActive:(BOOL)libraryFocusActive { + BOOL previousLibraryFocusActive = _libraryFocusActive; + _controllerNavigationEnabled = enabled; + _libraryFocusActive = enabled && libraryFocusActive; + + if (!_controllerNavigationEnabled) { + _libraryFocusActive = NO; + [self clearButtonFocusChrome:_settingsButton]; + [self clearButtonFocusChrome:_profileButton]; + [self clearButtonFocusChrome:_openGameButton]; + if (_focusedGameIndex >= 0 && _focusedGameIndex < static_cast(_games.count)) { + NSIndexPath* focusedPath = [NSIndexPath indexPathForItem:_focusedGameIndex inSection:0]; + [_gamesCollectionView reloadItemsAtIndexPaths:@[ focusedPath ]]; + } + return; + } + + [self setButton:_settingsButton controllerFocused:settingsFocused]; + [self setButton:_profileButton controllerFocused:profileFocused]; + [self setButton:_openGameButton controllerFocused:importFocused]; + + if (_libraryFocusActive && _focusedGameIndex < 0 && _games.count > 0) { + [self setFocusedGameIndex:0 scroll:NO]; + } + + if (previousLibraryFocusActive != _libraryFocusActive && _focusedGameIndex >= 0 && + _focusedGameIndex < static_cast(_games.count)) { + NSIndexPath* focusedPath = [NSIndexPath indexPathForItem:_focusedGameIndex inSection:0]; + [_gamesCollectionView reloadItemsAtIndexPaths:@[ focusedPath ]]; + } +} + +#pragma mark - Data ingestion + +- (void)setGames:(NSArray*)games { + [_games release]; + _games = [games copy]; + _emptyLabel.hidden = _games.count > 0; + if (_games.count == 0) { + _focusedGameIndex = -1; + } else if (_focusedGameIndex < 0 || _focusedGameIndex >= static_cast(_games.count)) { + _focusedGameIndex = 0; + } + [_gamesCollectionView reloadData]; +} + +- (void)setJITAcquired:(BOOL)acquired { + BOOL previous_acquired = _jitAcquired; + _jitAcquired = acquired; + if (acquired) { + _jitStatusDot.backgroundColor = [XeniaTheme accent]; + _jitStatusLabel.text = @"JIT Enabled"; + _jitReadyDot.hidden = NO; + _jitReadyLabel.hidden = NO; + _jitReadyLabel.text = @"JIT Enabled"; + [_jitStatusRing.layer removeAllAnimations]; + _jitStatusRing.alpha = 0; + xe_add_jit_ring_pulse(_jitReadyRing.layer, @"xenia.jit.ready.pulse", 1.7, 0.5, 2.0); + } else { + _jitStatusDot.backgroundColor = [XeniaTheme statusError]; + _jitReadyDot.hidden = YES; + _jitReadyLabel.hidden = YES; + [_jitReadyRing.layer removeAllAnimations]; + _jitReadyRing.alpha = 0; + xe_add_jit_ring_pulse(_jitStatusRing.layer, @"xenia.jit.warn.pulse", 1.55, 0.42, 1.9); + } + + BOOL previousHidden = _jitWarningCard.hidden; + _jitWarningCard.hidden = acquired; + if (previousHidden != _jitWarningCard.hidden) { + [_gamesCollectionView.collectionViewLayout invalidateLayout]; + } + _jitWarningCard.accessibilityValue = acquired ? @"JIT is enabled" : _jitStatusLabel.text; + _jitReadyLabel.accessibilityLabel = acquired ? @"JIT is enabled" : @"JIT is not enabled"; + if (previous_acquired != acquired) { + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, + acquired ? @"JIT is enabled" : _jitStatusLabel.text); + } + [self applyActionsEnabledState]; +} + +- (void)setJITStatusText:(NSString*)text { + _jitStatusLabel.text = text ?: @""; + _jitWarningCard.accessibilityValue = _jitStatusLabel.text; +} + +- (void)setFocusedGameIndex:(NSInteger)index scroll:(BOOL)scroll { + if (_games.count == 0) { + index = -1; + } else { + if (index < 0) { + index = 0; + } + NSInteger maxIndex = static_cast(_games.count - 1); + if (index > maxIndex) { + index = maxIndex; + } + } + NSInteger previous = _focusedGameIndex; + _focusedGameIndex = index; + + NSMutableArray* reloadPaths = [NSMutableArray array]; + if (previous >= 0 && previous < static_cast(_games.count)) { + [reloadPaths addObject:[NSIndexPath indexPathForItem:previous inSection:0]]; + } + if (_focusedGameIndex >= 0 && _focusedGameIndex < static_cast(_games.count) && + _focusedGameIndex != previous) { + [reloadPaths addObject:[NSIndexPath indexPathForItem:_focusedGameIndex inSection:0]]; + } + if (reloadPaths.count > 0) { + [_gamesCollectionView reloadItemsAtIndexPaths:reloadPaths]; + } + + if (scroll && _focusedGameIndex >= 0 && + _focusedGameIndex < static_cast(_games.count)) { + NSIndexPath* path = [NSIndexPath indexPathForItem:_focusedGameIndex inSection:0]; + [_gamesCollectionView scrollToItemAtIndexPath:path + atScrollPosition:UICollectionViewScrollPositionCenteredVertically + animated:YES]; + } +} + +- (NSInteger)focusedGameIndex { + return _focusedGameIndex; +} + +- (void)reloadGames { + [_gamesCollectionView reloadData]; +} + +- (UILabel*)statusLabel { + return _statusLabel; +} + +- (UICollectionView*)gamesCollectionView { + return _gamesCollectionView; +} + +- (BOOL)isOverlayVisible { + return !self.hidden; +} + +- (NSInteger)pageStep { + NSArray* visible = _gamesCollectionView.indexPathsForVisibleItems; + if (visible.count > 0) { + return visible.count; + } + return 6; +} + +- (void)refreshChromeForCurrentTraits { + _jitReadyRing.layer.borderColor = [XeniaTheme accent].CGColor; + _jitWarningCard.layer.borderColor = [XeniaTheme border].CGColor; + _jitStatusRing.layer.borderColor = _jitStatusDot.backgroundColor + ? _jitStatusDot.backgroundColor.CGColor + : [XeniaTheme statusError].CGColor; + [_gamesCollectionView reloadData]; +} + +- (void)setOverlayVisible:(BOOL)visible animated:(BOOL)animated { + if (visible == !self.hidden) { + return; + } + if (!animated) { + self.hidden = !visible; + self.alpha = visible ? 1.0 : 0.0; + return; + } + if (visible) { + self.hidden = NO; + self.alpha = 0.0; + [UIView animateWithDuration:0.3 + animations:^{ + self.alpha = 1.0; + }]; + } else { + [UIView animateWithDuration:0.3 + animations:^{ + self.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + self.hidden = YES; + self.alpha = 1.0; + }]; + } +} + +#pragma mark - Grid sizing helpers + +- (BOOL)usesCompactLandscapeLayoutForContentSize:(CGSize)contentSize { + return contentSize.width > contentSize.height && contentSize.height < 430.0f; +} + +- (NSInteger)columnCountForContentSize:(CGSize)contentSize { + CGFloat contentWidth = contentSize.width; + BOOL compactLandscape = [self usesCompactLandscapeLayoutForContentSize:contentSize]; + CGFloat gridSpacing = compactLandscape ? 12.0f : 16.0f; + CGFloat minimumTileWidth = compactLandscape ? 170.0f : 190.0f; + NSInteger minimumColumns = compactLandscape ? 4 : 2; + NSInteger maximumColumns = compactLandscape ? 5 : 6; + + NSInteger columns = static_cast( + floor((contentWidth + gridSpacing) / (minimumTileWidth + gridSpacing))); + columns = MAX(columns, minimumColumns); + columns = MIN(columns, maximumColumns); + return columns; +} + +- (NSInteger)columnCount { + return [self columnCountForContentSize:_gamesCollectionView.bounds.size]; +} + +- (CGFloat)interitemSpacingForCollectionView:(UICollectionView*)collectionView { + return [self usesCompactLandscapeLayoutForContentSize:collectionView.bounds.size] ? 12.0f : 16.0f; +} + +- (CGFloat)lineSpacingForCollectionView:(UICollectionView*)collectionView { + return [self usesCompactLandscapeLayoutForContentSize:collectionView.bounds.size] ? 14.0f : 20.0f; +} + +- (CGFloat)titleHeightForCollectionView:(UICollectionView*)collectionView { + return [self usesCompactLandscapeLayoutForContentSize:collectionView.bounds.size] ? 56.0f : 60.0f; +} + +- (CGFloat)tileWidthForCollectionView:(UICollectionView*)collectionView + columns:(NSInteger)columns + interitemSpacing:(CGFloat)spacing { + CGFloat contentWidth = collectionView.bounds.size.width; + CGFloat totalSpacing = spacing * MAX(columns - 1, 0); + static constexpr CGFloat kTileShadowMargin = 16.0f; + CGFloat availableWidth = MAX(contentWidth - totalSpacing - kTileShadowMargin, 0.0f); + CGFloat tileWidth = availableWidth / MAX(columns, 1); + CGFloat screenScale = + collectionView.window.screen ? collectionView.window.screen.scale : UIScreen.mainScreen.scale; + tileWidth = floor(tileWidth * screenScale) / screenScale; + return MAX(tileWidth, 100.0f); +} + +#pragma mark - UICollectionViewDataSource + +- (NSInteger)collectionView:(UICollectionView* __unused)collectionView + numberOfItemsInSection:(NSInteger)__unused section { + return static_cast(_games.count); +} + +- (UICollectionViewCell*)collectionView:(UICollectionView*)collectionView + cellForItemAtIndexPath:(NSIndexPath*)indexPath { + XeniaGameTileCell* cell = + [collectionView dequeueReusableCellWithReuseIdentifier:@"ImportedGameCell" + forIndexPath:indexPath]; + if (indexPath.item < 0 || static_cast(indexPath.item) >= _games.count) { + cell.titleLabel.text = @""; + cell.iconView.image = nil; + cell.contentTypePill.text = @""; + cell.contentTypePill.hidden = YES; + cell.compatPill.text = @""; + cell.compatPill.hidden = YES; + cell.controllerFocused = NO; + cell.accessibilityLabel = nil; + cell.accessibilityValue = nil; + return cell; + } + + XeniaIOSLauncherGameSnapshot* game = _games[static_cast(indexPath.item)]; + cell.titleLabel.text = game.title; + cell.contentTypePill.text = game.contentTypeName ?: @""; + cell.contentTypePill.hidden = game.contentTypeName.length == 0; + cell.contentTypePill.textColor = [XeniaTheme textMuted]; + cell.contentTypePill.backgroundColor = [UIColor clearColor]; + cell.contentTypePill.layer.borderColor = [UIColor clearColor].CGColor; + if (game.hasCompatInfo) { + cell.compatPill.text = xe_compat_status_label(game.compatStatus); + UIColor* pillColor = xe_compat_status_color(game.compatStatus); + cell.compatPill.textColor = pillColor; + cell.compatPill.backgroundColor = [UIColor clearColor]; + cell.compatPill.layer.borderWidth = 0.0; + cell.compatPill.layer.borderColor = [UIColor clearColor].CGColor; + cell.compatPill.hidden = NO; + } else { + cell.compatPill.text = @"Unknown"; + cell.compatPill.textColor = [XeniaTheme textMuted]; + cell.compatPill.backgroundColor = [UIColor clearColor]; + cell.compatPill.layer.borderWidth = 0.0; + cell.compatPill.layer.borderColor = [UIColor clearColor].CGColor; + cell.compatPill.hidden = NO; + } + cell.controllerFocused = + _controllerNavigationEnabled && _libraryFocusActive && _focusedGameIndex == indexPath.item; + NSMutableArray* accessibility_values = [NSMutableArray array]; + if (game.contentTypeName.length) { + [accessibility_values addObject:game.contentTypeName]; + } + if (game.hasCompatInfo) { + [accessibility_values addObject:xe_compat_status_label(game.compatStatus)]; + } else { + [accessibility_values addObject:@"Unknown compatibility"]; + } + cell.accessibilityLabel = game.title.length ? game.title : @"Untitled game"; + cell.accessibilityValue = [accessibility_values componentsJoinedByString:@", "]; + cell.accessibilityHint = @"Opens the game. Long press for more actions."; + cell.accessibilityTraits = UIAccessibilityTraitButton; + + UIImage* icon = game.supportsRemoteArt ? xe_cached_game_art(game.titleId) : nil; + if (icon) { + cell.iconView.image = icon; + } else { + UIImage* fallback = nil; + if (game.iconData.length) { + fallback = [UIImage imageWithData:game.iconData]; + } + if (!fallback) { + fallback = [UIImage imageNamed:@"128"]; + } + if (!fallback) { + fallback = [UIImage systemImageNamed:@"gamecontroller.fill"]; + } + cell.iconView.image = fallback; + if (game.titleId && game.supportsRemoteArt) { + uint32_t fetchTitleId = game.titleId; + UICollectionView* cv = collectionView; + xe_fetch_game_art(fetchTitleId, ^(UIImage* fetched) { + if (!fetched || !cv) { + return; + } + // Reload any cell that shares this title ID. + __unsafe_unretained XeniaIOSLauncherOverlayView* unsafeSelf = self; + NSMutableArray* reloadPaths = [NSMutableArray array]; + for (NSUInteger i = 0; i < unsafeSelf->_games.count; ++i) { + if (unsafeSelf->_games[i].titleId == fetchTitleId) { + [reloadPaths addObject:[NSIndexPath indexPathForItem:static_cast(i) + inSection:0]]; + } + } + if (reloadPaths.count > 0) { + [cv reloadItemsAtIndexPaths:reloadPaths]; + } + }); + } + } + return cell; +} + +#pragma mark - UICollectionViewDelegate + +- (void)collectionView:(UICollectionView*)collectionView + didSelectItemAtIndexPath:(NSIndexPath*)indexPath { + [collectionView deselectItemAtIndexPath:indexPath animated:YES]; + if (indexPath.item < 0 || static_cast(indexPath.item) >= _games.count) { + return; + } + [self setFocusedGameIndex:indexPath.item scroll:NO]; + if (_gameLaunchedHandler) { + _gameLaunchedHandler(static_cast(indexPath.item)); + } +} + +- (UITargetedPreview*)contextMenuPreviewForConfiguration:(UIContextMenuConfiguration*)configuration + collectionView:(UICollectionView*)collectionView { + id identifier = configuration.identifier; + NSIndexPath* indexPath = + [identifier isKindOfClass:[NSIndexPath class]] ? (NSIndexPath*)identifier : nil; + if (!indexPath) { + return nil; + } + XeniaGameTileCell* cell = (XeniaGameTileCell*)[collectionView cellForItemAtIndexPath:indexPath]; + if (![cell isKindOfClass:[XeniaGameTileCell class]]) { + return nil; + } + UIPreviewParameters* parameters = [[[UIPreviewParameters alloc] init] autorelease]; + parameters.backgroundColor = [UIColor clearColor]; + parameters.visiblePath = [UIBezierPath bezierPathWithRoundedRect:cell.cardView.bounds + cornerRadius:XeniaRadiusLg]; + return [[[UITargetedPreview alloc] initWithView:cell.cardView parameters:parameters] autorelease]; +} + +- (void)resetContextMenuCellTransforms { + for (UICollectionViewCell* visibleCell in _gamesCollectionView.visibleCells) { + if (![visibleCell isKindOfClass:[XeniaGameTileCell class]]) { + continue; + } + XeniaGameTileCell* gameCell = (XeniaGameTileCell*)visibleCell; + gameCell.transform = CGAffineTransformIdentity; + gameCell.contentView.transform = CGAffineTransformIdentity; + gameCell.cardView.transform = CGAffineTransformIdentity; + gameCell.layer.zPosition = gameCell.controllerFocused ? 1.0f : 0.0f; + [gameCell setNeedsLayout]; + } + [_gamesCollectionView.collectionViewLayout invalidateLayout]; +} + +- (UIContextMenuConfiguration*)collectionView:(UICollectionView*)collectionView + contextMenuConfigurationForItemAtIndexPath:(NSIndexPath*)indexPath + point:(CGPoint)point { + (void)collectionView; + (void)point; + if (indexPath.item < 0 || static_cast(indexPath.item) >= _games.count) { + return nil; + } + + NSUInteger gameIndex = static_cast(indexPath.item); + return [UIContextMenuConfiguration + configurationWithIdentifier:indexPath + previewProvider:nil + actionProvider:^UIMenu*(NSArray* __unused suggestedActions) { + __unsafe_unretained XeniaIOSLauncherOverlayView* unsafeSelf = self; + if (gameIndex >= unsafeSelf->_games.count) { + return nil; + } + XeniaIOSLauncherGameSnapshot* game = unsafeSelf->_games[gameIndex]; + + UIAction* playAction = + [UIAction actionWithTitle:@"Play" + image:[UIImage systemImageNamed:@"play.fill"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_gameLaunchedHandler) { + unsafeSelf->_gameLaunchedHandler(gameIndex); + } + }]; + UIAction* gameSettingsAction = + [UIAction actionWithTitle:@"Game Settings" + image:[UIImage systemImageNamed:@"slider.horizontal.3"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_gameSettingsHandler) { + unsafeSelf->_gameSettingsHandler(gameIndex); + } + }]; + UIAction* touchLayoutAction = + [UIAction actionWithTitle:@"Touch Layout" + image:[UIImage systemImageNamed:@"hand.tap"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_touchLayoutHandler) { + unsafeSelf->_touchLayoutHandler(gameIndex); + } + }]; + UIAction* compatAction = + [UIAction actionWithTitle:@"Compatibility" + image:[UIImage systemImageNamed:@"checkmark.shield"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_compatibilityHandler) { + unsafeSelf->_compatibilityHandler(gameIndex); + } + }]; + UIAction* contentAction = + [UIAction actionWithTitle:@"Manage Content" + image:[UIImage systemImageNamed:@"square.stack.3d.up"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_manageContentHandler) { + unsafeSelf->_manageContentHandler(gameIndex); + } + }]; + UIAction* discAction = + [UIAction actionWithTitle:@"Launch Disc" + image:[UIImage systemImageNamed:@"opticaldisc"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_discSelectionHandler) { + unsafeSelf->_discSelectionHandler(gameIndex); + } + }]; + UIAction* patchesAction = [UIAction + actionWithTitle:@"Patches" + image:[UIImage systemImageNamed:@"puzzlepiece.extension"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_patchesHandler) { + unsafeSelf->_patchesHandler(gameIndex); + } + }]; + UIAction* copyURLAction = + [UIAction actionWithTitle:@"Copy Launch URL" + image:[UIImage systemImageNamed:@"link"] + identifier:nil + handler:^(__unused UIAction* action) { + if (unsafeSelf->_copyLaunchURLHandler) { + unsafeSelf->_copyLaunchURLHandler(gameIndex); + } + }]; + if (!game.supportsCompatibility) { + compatAction.attributes = UIMenuElementAttributesDisabled; + } + if (!game.supportsManageContent) { + contentAction.attributes = UIMenuElementAttributesDisabled; + } + if (!game.supportsDiscSelection) { + discAction.attributes = UIMenuElementAttributesDisabled; + } + if (!game.supportsPatches) { + patchesAction.attributes = UIMenuElementAttributesDisabled; + } + if (!game.titleId) { + gameSettingsAction.attributes = UIMenuElementAttributesDisabled; + touchLayoutAction.attributes = UIMenuElementAttributesDisabled; + copyURLAction.attributes = UIMenuElementAttributesDisabled; + } + return [UIMenu menuWithTitle:@"" + children:@[ + playAction, gameSettingsAction, touchLayoutAction, + discAction, compatAction, contentAction, patchesAction, + copyURLAction + ]]; + }]; +} + +- (UITargetedPreview*)collectionView:(UICollectionView*)collectionView + previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration*)configuration { + return [self contextMenuPreviewForConfiguration:configuration collectionView:collectionView]; +} + +- (UITargetedPreview*)collectionView:(UICollectionView*)collectionView + previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration*)configuration { + return [self contextMenuPreviewForConfiguration:configuration collectionView:collectionView]; +} + +- (void)collectionView:(UICollectionView*)collectionView + willEndContextMenuInteractionWithConfiguration:(UIContextMenuConfiguration*)configuration + animator:(id)animator { + (void)collectionView; + (void)configuration; + [animator addCompletion:^{ + [self resetContextMenuCellTransforms]; + }]; +} + +#pragma mark - UICollectionViewDelegateFlowLayout + +- (CGSize)collectionView:(UICollectionView*)collectionView + layout:(UICollectionViewLayout* __unused)collectionViewLayout + sizeForItemAtIndexPath:(NSIndexPath* __unused)indexPath { + NSInteger columns = [self columnCountForContentSize:collectionView.bounds.size]; + CGFloat spacing = [self interitemSpacingForCollectionView:collectionView]; + CGFloat tileWidth = [self tileWidthForCollectionView:collectionView + columns:columns + interitemSpacing:spacing]; + CGFloat imageHeight = ceil(tileWidth * 300.0f / 219.0f); + return CGSizeMake(tileWidth, imageHeight + [self titleHeightForCollectionView:collectionView]); +} + +- (CGFloat)collectionView:(UICollectionView*)collectionView + layout:(UICollectionViewLayout* __unused)collectionViewLayout + minimumInteritemSpacingForSectionAtIndex:(NSInteger)__unused section { + return [self interitemSpacingForCollectionView:collectionView]; +} + +- (CGFloat)collectionView:(UICollectionView*)collectionView + layout:(UICollectionViewLayout* __unused)collectionViewLayout + minimumLineSpacingForSectionAtIndex:(NSInteger)__unused section { + return [self lineSpacingForCollectionView:collectionView]; +} + +- (UIEdgeInsets)collectionView:(UICollectionView*)collectionView + layout:(UICollectionViewLayout* __unused)collectionViewLayout + insetForSectionAtIndex:(NSInteger)__unused section { + NSInteger columns = [self columnCountForContentSize:collectionView.bounds.size]; + CGFloat spacing = [self interitemSpacingForCollectionView:collectionView]; + CGFloat tileWidth = [self tileWidthForCollectionView:collectionView + columns:columns + interitemSpacing:spacing]; + CGFloat consumedWidth = tileWidth * columns + spacing * MAX(columns - 1, 0); + CGFloat remainder = MAX(collectionView.bounds.size.width - consumedWidth, 0.0f); + CGFloat screenScale = + collectionView.window.screen ? collectionView.window.screen.scale : UIScreen.mainScreen.scale; + CGFloat leftInset = floor((remainder * 0.5f) * screenScale) / screenScale; + CGFloat rightInset = MAX(remainder - leftInset, 0.0f); + return UIEdgeInsetsMake(0.0f, leftInset, 0.0f, rightInset); +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_choice_list_view_controller.h b/src/xenia/ui/ios/settings/ios_choice_list_view_controller.h new file mode 100644 index 000000000..67ff840e1 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_choice_list_view_controller.h @@ -0,0 +1,34 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CHOICE_LIST_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_CHOICE_LIST_VIEW_CONTROLLER_H_ + +#import + +#include +#include + +#include "xenia/ui/ios/settings/ios_config_models.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +typedef void (^IOSChoiceSelectionHandler)(int64_t value); + +// Pushed onto the settings nav stack to pick a single value from a list of +// IOSConfigChoice rows. Confirms the new selection back to the caller via +// IOSChoiceSelectionHandler and pops itself. +@interface XeniaChoiceListViewController : XESheetTableViewController +- (instancetype)initWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + choices:(const std::vector&)choices + selectedValue:(int64_t)selectedValue + onSelection:(IOSChoiceSelectionHandler)onSelection; +@end + +#endif // XENIA_UI_IOS_CHOICE_LIST_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_choice_list_view_controller.mm b/src/xenia/ui/ios/settings/ios_choice_list_view_controller.mm new file mode 100644 index 000000000..18a928964 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_choice_list_view_controller.mm @@ -0,0 +1,115 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_choice_list_view_controller.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaChoiceListViewController { + std::vector choices_; + int64_t selected_value_; + NSString* subtitle_; + IOSChoiceSelectionHandler on_selection_; +} + +- (instancetype)initWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + choices:(const std::vector&)choices + selectedValue:(int64_t)selectedValue + onSelection:(IOSChoiceSelectionHandler)onSelection { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + self.title = title; + subtitle_ = [subtitle copy]; + choices_ = choices; + selected_value_ = selectedValue; + on_selection_ = [onSelection copy]; + } + return self; +} + +- (void)dealloc { + [subtitle_ release]; + [on_selection_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 16, 0, 16); + + if (subtitle_.length > 0) { + UILabel* header_label = [[UILabel alloc] initWithFrame:CGRectZero]; + header_label.text = subtitle_; + header_label.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(header_label, UIFontTextStyleFootnote, 13.0, UIFontWeightRegular); + header_label.numberOfLines = 0; + header_label.textAlignment = NSTextAlignmentLeft; + header_label.translatesAutoresizingMaskIntoConstraints = NO; + header_label.isAccessibilityElement = YES; + header_label.accessibilityTraits = UIAccessibilityTraitHeader; + + UIView* header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 56)]; + [header addSubview:header_label]; + [NSLayoutConstraint activateConstraints:@[ + [header_label.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:18], + [header_label.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-18], + [header_label.topAnchor constraintEqualToAnchor:header.topAnchor constant:8], + [header_label.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-8], + ]]; + self.tableView.tableHeaderView = header; + } +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)__unused section { + return static_cast(choices_.size()); +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kChoiceCellIdentifier = @"XeniaChoiceCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kChoiceCellIdentifier]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:kChoiceCellIdentifier]; + } + if (indexPath.row < 0 || indexPath.row >= static_cast(choices_.size())) { + cell.textLabel.text = @""; + cell.accessoryType = UITableViewCellAccessoryNone; + return cell; + } + const IOSConfigChoice& choice = choices_[indexPath.row]; + cell.textLabel.text = ToNSString(choice.title); + cell.accessoryType = (choice.value == selected_value_) ? UITableViewCellAccessoryCheckmark + : UITableViewCellAccessoryNone; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = cell.textLabel.text; + cell.accessibilityValue = (choice.value == selected_value_) ? @"Selected" : @"Not selected"; + cell.accessibilityTraits = + UIAccessibilityTraitButton | + ((choice.value == selected_value_) ? UIAccessibilityTraitSelected : 0); + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.row < 0 || indexPath.row >= static_cast(choices_.size())) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + const IOSConfigChoice& choice = choices_[indexPath.row]; + selected_value_ = choice.value; + if (on_selection_) { + on_selection_(selected_value_); + } + [self.navigationController popViewControllerAnimated:YES]; +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_config_builder.h b/src/xenia/ui/ios/settings/ios_config_builder.h new file mode 100644 index 000000000..2569b9fd1 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_builder.h @@ -0,0 +1,23 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONFIG_BUILDER_H_ +#define XENIA_UI_IOS_CONFIG_BUILDER_H_ + +#import + +#include "xenia/ui/ios/settings/ios_config_catalog.h" +#include "xenia/ui/ios/settings/ios_config_storage.h" + +// Bridge between Xenia's cvar / NSUserDefaults state and the iOS settings +// sheet. BuildIOSConfigSections constructs the rows the sheet shows; +// ApplyIOSConfigSections writes them back into the cvar registry / user +// defaults and persists the config on disk. + +#endif // XENIA_UI_IOS_CONFIG_BUILDER_H_ diff --git a/src/xenia/ui/ios/settings/ios_config_builder.mm b/src/xenia/ui/ios/settings/ios_config_builder.mm new file mode 100644 index 000000000..f69e18dae --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_builder.mm @@ -0,0 +1,10 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_config_builder.h" diff --git a/src/xenia/ui/ios/settings/ios_config_catalog.h b/src/xenia/ui/ios/settings/ios_config_catalog.h new file mode 100644 index 000000000..f624dee7e --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_catalog.h @@ -0,0 +1,26 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONFIG_CATALOG_H_ +#define XENIA_UI_IOS_CONFIG_CATALOG_H_ + +#include +#include + +#include "xenia/ui/ios/settings/ios_config_models.h" + +std::vector BuildIOSConfigSections(void); +std::vector BuildIOSConfigSectionsForKind( + IOSConfigCatalogKind kind); +std::vector BuildAllCvarSections(void); +std::vector BuildDebugSettingsSections(void); +std::string IOSConfigCatalogTitle(IOSConfigCatalogKind kind); +std::string ChoiceTitleForItem(const IOSConfigItem& item); + +#endif // XENIA_UI_IOS_CONFIG_CATALOG_H_ diff --git a/src/xenia/ui/ios/settings/ios_config_catalog.mm b/src/xenia/ui/ios/settings/ios_config_catalog.mm new file mode 100644 index 000000000..d364c60df --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_catalog.mm @@ -0,0 +1,949 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_config_catalog.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/ui/config_helpers.h" + +#import "xenia/ui/ios/settings/ios_config_storage.h" + +static std::string TrimAscii(std::string value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + ++start; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +static void AddBoolSetting(std::vector& items, const std::string& key, + const std::string& title, const std::string& subtitle, bool fallback) { + if (!IOSConfigHasConfigVar(key)) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kToggle; + item.bool_value = fallback; + IOSConfigParseBoolString(IOSConfigGetConfigVarString(key, fallback ? "true" : "false"), + &item.bool_value); + items.push_back(std::move(item)); +} + +static void AddUserDefaultBoolSetting(std::vector& items, NSString* key, + const std::string& title, const std::string& subtitle, + bool fallback) { + if (!key || key.length == 0) { + return; + } + IOSConfigItem item; + item.key = std::string([key UTF8String]); + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kToggle; + item.storage = IOSConfigStorage::kUserDefaults; + item.bool_value = GetUserDefaultBool(key, fallback); + items.push_back(std::move(item)); +} + +static void AddChoiceSetting(std::vector& items, IOSConfigControlType control_type, + const std::string& key, const std::string& title, + const std::string& subtitle, int64_t fallback, + std::vector choices) { + if (!IOSConfigHasConfigVar(key) || choices.empty()) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = control_type; + item.choice_value = fallback; + IOSConfigParseInt64String(IOSConfigGetConfigVarString(key, std::to_string(fallback)), + &item.choice_value); + item.choices = std::move(choices); + bool found = false; + for (const IOSConfigChoice& choice : item.choices) { + if (choice.value == item.choice_value) { + found = true; + break; + } + } + if (!found) { + item.choice_value = item.choices.front().value; + } + items.push_back(std::move(item)); +} + +static void AddStringChoiceSetting(std::vector& items, const std::string& key, + const std::string& title, const std::string& subtitle, + const std::string& fallback, + std::vector> choices) { + if (!IOSConfigHasConfigVar(key) || choices.empty()) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kChoiceString; + item.string_value = IOSConfigGetConfigVarString(key, fallback); + + for (size_t i = 0; i < choices.size(); ++i) { + item.choices.push_back({choices[i].first, static_cast(i)}); + item.choice_string_values.push_back(choices[i].second); + } + + bool found = false; + for (size_t i = 0; i < item.choice_string_values.size(); ++i) { + if (item.choice_string_values[i] == item.string_value) { + item.choice_value = static_cast(i); + found = true; + break; + } + } + if (!found) { + item.string_value = fallback; + for (size_t i = 0; i < item.choice_string_values.size(); ++i) { + if (item.choice_string_values[i] == fallback) { + item.choice_value = static_cast(i); + found = true; + break; + } + } + } + if (!found) { + item.choice_value = 0; + item.string_value = item.choice_string_values.front(); + } + + items.push_back(std::move(item)); +} + +static void AddActionSetting(std::vector& items, IOSConfigAction action, + const std::string& title, const std::string& subtitle) { + IOSConfigItem item; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kAction; + item.action = action; + items.push_back(std::move(item)); +} + +static void AddEnumSetting(std::vector& items, const std::string& key, + const std::string& title, const std::string& subtitle) { + if (!IOSConfigHasConfigVar(key)) { + return; + } + const auto& enum_opts = xe::ui::GetKnownEnumOptions(); + auto it = enum_opts.find(key); + if (it == enum_opts.end() || it->second.empty()) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kEnum; + item.enum_key = key; + item.string_value = IOSConfigGetConfigVarString(key, it->second.front()); + for (size_t i = 0; i < it->second.size(); ++i) { + item.choices.push_back({it->second[i], static_cast(i)}); + item.choice_string_values.push_back(it->second[i]); + if (it->second[i] == item.string_value) { + item.choice_value = static_cast(i); + } + } + items.push_back(std::move(item)); +} + +static void AddIntegerSetting(std::vector& items, const std::string& key, + const std::string& title, const std::string& subtitle) { + if (!IOSConfigHasConfigVar(key)) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kInteger; + int64_t parsed = 0; + if (IOSConfigParseInt64String(IOSConfigGetConfigVarString(key, "0"), &parsed)) { + item.integer_value = parsed; + } + items.push_back(std::move(item)); +} + +static void AddDoubleSetting(std::vector& items, const std::string& key, + const std::string& title, const std::string& subtitle) { + if (!IOSConfigHasConfigVar(key)) { + return; + } + IOSConfigItem item; + item.key = key; + item.title = title; + item.subtitle = subtitle; + item.control_type = IOSConfigControlType::kDouble; + std::string raw = IOSConfigGetConfigVarString(key, "0"); + if (!raw.empty()) { + char* end = nullptr; + double parsed = std::strtod(raw.c_str(), &end); + if (end != raw.c_str() && *end == '\0') { + item.double_value = parsed; + } + } + items.push_back(std::move(item)); +} + +std::string ChoiceTitleForItem(const IOSConfigItem& item) { + for (const IOSConfigChoice& choice : item.choices) { + if (choice.value == item.choice_value) { + return choice.title; + } + } + return item.choices.empty() ? std::string() : item.choices.front().title; +} + +static void PushIfNotEmpty(std::vector& sections, + IOSConfigSection section) { + if (!section.items.empty()) { + sections.push_back(std::move(section)); + } +} + +static std::vector BuildDisplaySections() { + std::vector sections; + IOSConfigSection display; + display.title = "Display"; + display.footer = "These settings affect frame pacing and the Metal presenter output."; + AddBoolSetting(display.items, "metal_presenter_force_10bpc", "Force 10bpc Presenter Output", + "Metal-only. Uses RGB10A2 output, which is the default path and usually " + "reduces gamma-conversion cost on Apple GPUs. Disable only if colors, " + "captures, or display compatibility look wrong.", + true); + AddBoolSetting(display.items, "present_letterbox", "Letterbox", + "Maintains the guest display aspect with bars when the presenter surface " + "does not match the guest aspect. Disable for aspect-ratio patches that " + "should fill the current surface.", + true); + AddChoiceSetting(display.items, IOSConfigControlType::kChoiceUInt64, "framerate_limit", + "Frame Rate Limit", + "Caps host presentation only; guest timing is separate. Use this to " + "reduce heat and battery drain. 120 FPS only matters on high-refresh " + "displays.", + 0, + {{"Unlimited", 0}, + {"30 FPS", 30}, + {"45 FPS", 45}, + {"60 FPS", 60}, + {"90 FPS", 90}, + {"120 FPS", 120}}); + AddBoolSetting(display.items, "guest_display_refresh_cap", "Cap Guest Display Refresh", + "Keeps guest vblank at console timing instead of running as fast as " + "possible. Turn this off only for troubleshooting speed or timing-" + "sensitive boot issues.", + true); + AddBoolSetting(display.items, "use_50Hz_mode", "Use 50Hz PAL Timing", + "Only matters when guest refresh cap is enabled. Required by some PAL " + "titles; leave this off for most games to keep normal 60 Hz timing.", + false); + PushIfNotEmpty(sections, std::move(display)); + return sections; +} + +static std::vector BuildGraphicsSections() { + std::vector sections; + + IOSConfigSection backend; + backend.title = "Graphics Backend"; +#if defined(XE_IOS_MOLTENVK_ENABLED) + AddStringChoiceSetting(backend.items, "gpu", "Graphics Backend", + "Select the renderer used after the next full relaunch. Metal is the " + "primary iOS backend; Vulkan runs through the bundled MoltenVK " + "translation layer for comparison and compatibility testing.", + "metal", {{"Metal", "metal"}, {"Vulkan (MoltenVK)", "vulkan"}}); +#endif + AddIntegerSetting(backend.items, "anisotropic_override", "Anisotropic Override", + "Override anisotropic filtering level (-1 = auto, 0 = off, 1-5)."); + AddEnumSetting(backend.items, "render_target_path", "Render Target Path", + "Selects the render target implementation path after a relaunch."); + PushIfNotEmpty(sections, std::move(backend)); + + IOSConfigSection shader; + shader.title = "Shader & Pipeline"; + shader.footer = "These settings trade stutter, battery usage, and cache size."; + AddBoolSetting(shader.items, "store_shaders", "Persistent Shader Cache", + "Keeps translated shaders and pipelines on disk so later boots stutter " + "less. Disable only if you suspect cache corruption after an update.", + true); + AddBoolSetting(shader.items, "async_shader_compilation", "Async Shader Compilation", + "Compiles Metal shaders and pipelines in background threads to reduce " + "stutter. New effects may appear a moment late; turn this off if you " + "prefer blocking correctness over smoother frame pacing.", + false); + PushIfNotEmpty(sections, std::move(shader)); + return sections; +} + +static std::vector BuildPerformanceSections() { + std::vector sections; + + IOSConfigSection caching; + caching.title = "Caching & Compilation"; + caching.footer = "These settings trade stutter, battery usage, and cache size."; + AddBoolSetting(caching.items, "store_shaders", "Persistent Shader Cache", + "Keeps translated shaders and pipelines on disk so later boots stutter " + "less. Disable only if you suspect cache corruption after an update.", + true); + AddBoolSetting(caching.items, "async_shader_compilation", "Async Shader Compilation", + "Compiles Metal shaders and pipelines in background threads to reduce " + "stutter. New effects may appear a moment late; turn this off if you " + "prefer blocking correctness over smoother frame pacing.", + false); + PushIfNotEmpty(sections, std::move(caching)); + + IOSConfigSection scheduler; + scheduler.title = "Thread QoS"; + scheduler.footer = "Xenia's regular iOS threads use Default QoS. These options are " + "experimental promotions; leave them off unless you are testing CPU, GPU, " + "or audio scheduling behavior after a full relaunch."; + AddBoolSetting(scheduler.items, "ios_gpu_commands_user_initiated_qos", + "GPU Commands User-Initiated QoS", + "Runs the Metal command processor host thread at user-initiated QoS. " + "Try this first if frames appear to miss submission deadlines.", + false); + AddBoolSetting(scheduler.items, "ios_guest_threads_user_initiated_qos", + "Guest Threads User-Initiated QoS", + "Runs guest XThreads at user-initiated QoS. This may help CPU-bound " + "titles, but it can also compete with audio and rendering work.", + false); + AddBoolSetting(scheduler.items, "ios_emulator_thread_user_initiated_qos", + "Emulator Thread User-Initiated QoS", + "Runs the high-level emulator setup and launch thread at " + "user-initiated QoS. This usually matters less than GPU commands or " + "guest threads.", + false); + PushIfNotEmpty(sections, std::move(scheduler)); + return sections; +} + +static std::vector BuildAudioSections() { + std::vector sections; + + IOSConfigSection audio; + audio.title = "Audio"; + audio.footer = "These settings control mute state and XMA decoding behavior."; + AddBoolSetting(audio.items, "mute", "Mute Audio", + "Immediately silences all emulator audio. Useful for background testing " + "or silent repro runs.", + false); + AddChoiceSetting(audio.items, IOSConfigControlType::kChoiceUInt32, "apu_max_queued_frames", + "Audio Queue Depth", + "Larger queues reduce underruns at the cost of latency. Use 48 or 64 " + "only if audio still stutters after a full relaunch.", + 32, {{"32 Frames", 32}, {"48 Frames", 48}, {"64 Frames", 64}}); + AddStringChoiceSetting(audio.items, "xma_decoder", "XMA Decoder", + "Select the XMA decoder implementation. New is the current general " + "default; Old and Master are fallback paths for regressions, and Fake " + "disables XMA decode entirely.", + "new", + {{"New (Recommended)", "new"}, + {"Old", "old"}, + {"Master", "master"}, + {"Fake (No XMA Audio)", "fake"}}); + AddBoolSetting(audio.items, "use_dedicated_xma_thread", "Dedicated XMA Thread", + "Runs XMA decode work on a separate thread. On arm64 this is off by " + "default; enable only if audio stutters or decode work blocks the title, " + "since timing can change.", + false); + PushIfNotEmpty(sections, std::move(audio)); + return sections; +} + +static std::vector BuildControlsSections() { + std::vector sections; + IOSConfigSection touch; + touch.title = "Touch Behavior"; + touch.footer = "Lower look points increase camera sensitivity. Hold timings help " + "low-FPS games observe short touch input pulses."; + AddBoolSetting(touch.items, "ios_touch_haptics", "Touch Haptics", + "Play haptic feedback on touch button presses, control selection, and " + "snap engagement.", + true); + AddBoolSetting(touch.items, "ios_touch_overlay", "Show Touch Overlay", + "Show on-screen touch controls during gameplay when no hardware " + "controller is connected.", + true); + AddDoubleSetting(touch.items, "ios_touch_look_points_per_full_scale", + "Look Points Per Full Scale", + "Swipe distance needed to drive the touch look zone to full stick output."); + AddDoubleSetting(touch.items, "ios_touch_look_vertical_scale", "Look Vertical Scale", + "Extra multiplier for vertical swipe-look motion."); + AddDoubleSetting(touch.items, "ios_touch_look_hold_seconds", "Look Hold Seconds", + "How long touch look motion persists for low-FPS polling."); + AddDoubleSetting(touch.items, "ios_touch_button_tap_hold_seconds", "Button Tap Hold Seconds", + "How long tap-style touch buttons stay active for low-FPS polling."); + PushIfNotEmpty(sections, std::move(touch)); + return sections; +} + +static std::vector BuildCompatibilitySections() { + std::vector sections; + + IOSConfigSection gpu; + gpu.title = "GPU Workarounds"; + gpu.footer = "Leave these at their defaults unless a specific title needs them."; + AddBoolSetting(gpu.items, "half_pixel_offset", "Half-Pixel Offset", + "D3D9-style sampling behavior. Keep this on for correct post-processing " + "and UI in most games; disable only if a specific title shows blurred UI " + "or edge artifacts.", + true); + AddBoolSetting(gpu.items, "gpu_3d_to_2d_texture", "Treat 3D Textures as 2D", + "Compatibility workaround for titles that incorrectly sample 3D textures " + "as 2D. Keep this on unless it causes a specific regression.", + true); + AddBoolSetting(gpu.items, "gpu_allow_invalid_fetch_constants", + "Allow Invalid Fetch Constants", + "Unsafe workaround for titles with broken texture or vertex fetch " + "metadata. This can help a game boot or draw, but it may also introduce " + "corruption or hide a deeper bug.", + true); + AddEnumSetting(gpu.items, "readback_resolve", "Readback Resolve", + "Controls CPU readback of render-to-texture resolve results."); + AddEnumSetting(gpu.items, "occlusion_query", "Occlusion Query", + "Selects the occlusion query implementation."); + AddEnumSetting(gpu.items, "render_target_path", "Render Target Path", + "Selects the render target implementation path."); + PushIfNotEmpty(sections, std::move(gpu)); + + IOSConfigSection memory; + memory.title = "Memory & Boot"; + AddBoolSetting(memory.items, "mount_cache", "Mount Cache", + "Mounts the Xbox cache partition for titles that expect it. Keep " + "this on for normal behavior, but disabling it may fix cutscene " + "loop issues in games like Halo 3, ODST, Reach, etc.", + true); + AddBoolSetting(memory.items, "clear_memory_page_state", "Clear Memory Page State", + "Clear memory page tracking state between frames.", true); + AddBoolSetting(memory.items, "submit_on_primary_buffer_end", "Submit on Primary Buffer End", + "Submit command buffers at primary buffer end boundaries.", true); + PushIfNotEmpty(sections, std::move(memory)); + + IOSConfigSection jit; + jit.title = "JIT & Codegen"; + AddBoolSetting(jit.items, "a64_enable_host_guest_stack_synchronization", + "A64 Stack Synchronization", + "ARM64-only compatibility path that keeps host and guest stacks " + "synchronized across calls. Leave this off unless a game specifically " + "needs it to boot or unwind correctly.", + false); + AddBoolSetting(jit.items, "ios_jit_brk_prepare_fallback", + "External JIT Prepare Fallback", + "iOS ARM64 only. If iOS denies JIT page protection changes, ask an " + "external broker or helper to prepare the region and retry. This is only " + "useful on TXM or broker setups; otherwise it is unnecessary.", + true); + AddBoolSetting(jit.items, "ios_jit_brk_use_universal_0xf00d", + "Universal 0xF00D JIT Breakpoint", + "Use the modern universal BRK command for the external JIT broker. Keep " + "this on for current broker scripts; disable it only if you are using an " + "older legacy 0x69-only setup. This only matters when External JIT " + "Prepare Fallback is enabled.", + true); + PushIfNotEmpty(sections, std::move(jit)); + return sections; +} + +static std::vector BuildSystemSections() { + std::vector sections; + IOSConfigSection automation; + automation.title = "Automation"; + automation.footer = + "These options are stored locally in the iOS frontend rather than xenios.config.toml."; + AddUserDefaultBoolSetting( + automation.items, kXeniaAutoOpenStikDebugOnLaunchPreferenceKey, + "Auto-Enable JIT via StikDebug", + "On app open, jump into StikDebug with XeniOS's bundle ID so it can enable JIT and " + "relaunch XeniOS. Requires StikDebug, a valid pairing file, and your normal VPN / loopback " + "setup.", + false); + PushIfNotEmpty(sections, std::move(automation)); + return sections; +} + +static std::vector BuildDiagnosticsSections() { + std::vector sections; + IOSConfigSection diagnostics; + diagnostics.title = "Logging"; + AddChoiceSetting(diagnostics.items, IOSConfigControlType::kChoiceInt32, "log_level", + "Log Verbosity", + "Controls how much goes into xenia.log. Higher levels help debug issues " + "but increase log size and background overhead.", + 2, {{"Errors Only", 0}, {"Warnings", 1}, {"Info", 2}, {"Debug", 3}}); + AddActionSetting(diagnostics.items, IOSConfigAction::kViewRecentLog, "View Live Log", + "Open a live-updating xenia.log viewer so you can capture boot failures " + "without Xcode."); + PushIfNotEmpty(sections, std::move(diagnostics)); + + return sections; +} + +static std::vector BuildGraphicsCompatSections() { + std::vector sections; + IOSConfigSection compat; + compat.title = "Graphics Compatibility"; + compat.footer = "Live overrides for the current session. These do not save to config."; + + AddEnumSetting(compat.items, "readback_resolve", "Readback Resolve", + "Controls CPU readback of render-to-texture resolve results."); + AddEnumSetting(compat.items, "occlusion_query", "Occlusion Query", + "Selects the occlusion query implementation."); + AddEnumSetting(compat.items, "render_target_path", "Render Target Path", + "Selects the render target implementation path."); + AddBoolSetting(compat.items, "half_pixel_offset", "Half-Pixel Offset", + "D3D9-style sampling behavior used by most Xbox 360 rendering.", true); + AddBoolSetting(compat.items, "gpu_3d_to_2d_texture", "Treat 3D Textures as 2D", + "Compatibility workaround for titles that sample 3D textures as 2D.", true); + AddBoolSetting(compat.items, "gpu_allow_invalid_fetch_constants", + "Allow Invalid Fetch Constants", + "Allow invalid shader fetch constants to pass through.", true); + AddBoolSetting(compat.items, "readback_memexport", "Readback Memexport", + "Allow CPU readback of shader memory export data.", true); + AddBoolSetting(compat.items, "readback_memexport_fast", "Fast Readback Memexport", + "Use delayed readback for lower synchronization cost.", true); + + PushIfNotEmpty(sections, std::move(compat)); + return sections; +} + +std::vector BuildDebugSettingsSections() { + std::vector sections; + + IOSConfigSection common; + common.title = "Common Overrides"; + AddIntegerSetting(common.items, "anisotropic_override", "anisotropic_override", + "Texture anisotropic filtering override (-1 = no override)."); + AddBoolSetting(common.items, "gpu_allow_invalid_fetch_constants", + "gpu_allow_invalid_fetch_constants", + "Allow invalid shader fetch constants to pass through.", true); + AddBoolSetting(common.items, "gpu_3d_to_2d_texture", "gpu_3d_to_2d_texture", + "Treat 3D texture sampling as 2D for compatibility.", true); + AddBoolSetting(common.items, "half_pixel_offset", "half_pixel_offset", + "D3D9-style half-pixel offset behavior.", true); + AddBoolSetting(common.items, "submit_on_primary_buffer_end", + "submit_on_primary_buffer_end", + "Submit command buffers at primary buffer end boundaries.", true); + AddIntegerSetting(common.items, "occlusion_query_fake_lower_threshold", + "occlusion_query_fake_lower_threshold", + "Lower fake occlusion query threshold."); + AddIntegerSetting(common.items, "occlusion_query_fake_upper_threshold", + "occlusion_query_fake_upper_threshold", + "Upper fake occlusion query threshold."); + AddDoubleSetting(common.items, "occlusion_query_saturation", + "occlusion_query_saturation", + "Compress occlusion query sample counts before guest writeback."); + PushIfNotEmpty(sections, std::move(common)); + + IOSConfigSection presentation; + presentation.title = "Presentation / Display"; + AddBoolSetting(presentation.items, "present_letterbox", "present_letterbox", + "Maintain guest aspect ratio with letterboxing.", true); + PushIfNotEmpty(sections, std::move(presentation)); + + IOSConfigSection scaling; + scaling.title = "Resolution Scaling / Resolve"; + AddBoolSetting(scaling.items, "draw_resolution_scaled_texture_offsets", + "draw_resolution_scaled_texture_offsets", + "Adjust texture offsets when draw resolution scaling is active.", false); + AddBoolSetting(scaling.items, "readback_resolve_half_pixel_offset", + "readback_resolve_half_pixel_offset", + "Use centered sampling for scaled readback resolves.", false); + AddBoolSetting(scaling.items, "resolve_resolution_scale_fill_half_pixel_offset", + "resolve_resolution_scale_fill_half_pixel_offset", + "Fill half-pixel offset when resolution scale changes.", false); + PushIfNotEmpty(sections, std::move(scaling)); + + IOSConfigSection shader; + shader.title = "Shader / Driver Workarounds"; + AddBoolSetting(shader.items, "use_fuzzy_alpha_epsilon", "use_fuzzy_alpha_epsilon", + "Use fuzzy alpha epsilon behavior for shader output.", false); + AddBoolSetting(shader.items, "vulkan_precise_interpolation", + "vulkan_precise_interpolation", + "Use precise interpolation on Vulkan/MoltenVK backends.", false); + AddBoolSetting(shader.items, "dxbc_switch", "dxbc_switch", + "D3D12-only shader translator switch workaround.", true); + PushIfNotEmpty(sections, std::move(shader)); + + IOSConfigSection edram; + edram.title = "EDRAM / Draw Heuristics"; + AddBoolSetting(edram.items, "execute_unclipped_draw_vs_on_cpu", + "execute_unclipped_draw_vs_on_cpu", + "Execute unclipped draw vertex shader work on the CPU.", false); + AddBoolSetting(edram.items, "execute_unclipped_draw_vs_on_cpu_for_psi_render_backend", + "execute_unclipped_draw_vs_on_cpu_for_psi_render_backend", + "CPU unclipped-draw path for pixel-shader interlock render backends.", false); + AddBoolSetting(edram.items, "execute_unclipped_draw_vs_on_cpu_with_scissor", + "execute_unclipped_draw_vs_on_cpu_with_scissor", + "Use CPU unclipped-draw handling with scissor state.", false); + AddBoolSetting(edram.items, "mrt_edram_used_range_clamp_to_min", + "mrt_edram_used_range_clamp_to_min", + "Clamp MRT EDRAM used range to minimum bounds.", false); + PushIfNotEmpty(sections, std::move(edram)); + + IOSConfigSection depth; + depth.title = "Depth / Precision"; + AddBoolSetting(depth.items, "depth_float24_convert_in_pixel_shader", + "depth_float24_convert_in_pixel_shader", + "Convert float24 depth values in pixel shaders.", false); + AddBoolSetting(depth.items, "depth_float24_round", "depth_float24_round", + "Round float24 depth values.", false); + AddBoolSetting(depth.items, "depth_transfer_not_equal_test", + "depth_transfer_not_equal_test", + "Use not-equal testing for depth transfers.", false); + PushIfNotEmpty(sections, std::move(depth)); + + IOSConfigSection primitive; + primitive.title = "Primitive Conversion"; + AddBoolSetting(primitive.items, "force_convert_triangle_strips_to_lists", + "force_convert_triangle_strips_to_lists", + "Force triangle strips to triangle lists.", false); + AddBoolSetting(primitive.items, "force_convert_triangle_fans_to_lists", + "force_convert_triangle_fans_to_lists", + "Force triangle fans to triangle lists.", false); + AddBoolSetting(primitive.items, "force_convert_quad_lists_to_triangle_lists", + "force_convert_quad_lists_to_triangle_lists", + "Force quad lists to triangle lists.", false); + AddBoolSetting(primitive.items, "force_convert_line_loops_to_strips", + "force_convert_line_loops_to_strips", + "Force line loops to line strips.", false); + PushIfNotEmpty(sections, std::move(primitive)); + + IOSConfigSection memory; + memory.title = "Memory / Boot Hacks"; + AddBoolSetting(memory.items, "scribble_heap", "scribble_heap", + "Scribble allocated heap memory for debugging.", false); + AddIntegerSetting(memory.items, "scribble_heap_value", "scribble_heap_value", + "Scribble byte value used when heap scribbling is enabled."); + PushIfNotEmpty(sections, std::move(memory)); + + IOSConfigSection logging; + logging.title = "Diagnostics / Logging"; + AddIntegerSetting(logging.items, "log_level", "log_level", "Log verbosity level."); + AddIntegerSetting(logging.items, "log_mask", "log_mask", "Log category mask."); + AddBoolSetting(logging.items, "occlusion_query_log", "occlusion_query_log", + "Log occlusion query lifetime and summary stats.", false); + AddBoolSetting(logging.items, "gpu_debug_markers", "gpu_debug_markers", + "Insert GPU debug markers for capture tools.", false); + AddBoolSetting(logging.items, "disassemble_pm4", "disassemble_pm4", + "Disassemble PM4 packets in debug builds.", false); + AddBoolSetting(logging.items, "log_guest_driven_gpu_register_written_values", + "log_guest_driven_gpu_register_written_values", + "Log guest-written GPU register values in debug builds.", false); + AddBoolSetting(logging.items, "log_ringbuffer_kickoff_initiator_bts", + "log_ringbuffer_kickoff_initiator_bts", + "Log the pseudo-stacktrace that kicked the GPU ringbuffer.", false); + PushIfNotEmpty(sections, std::move(logging)); + + return sections; +} + +static std::vector BuildAllIOSConfigSections() { + std::vector sections; + auto append = [§ions](std::vector more) { + for (auto& section : more) { + sections.push_back(std::move(section)); + } + }; + append(BuildDisplaySections()); + append(BuildGraphicsSections()); + append(BuildAudioSections()); + append(BuildControlsSections()); + append(BuildPerformanceSections()); + append(BuildCompatibilitySections()); + append(BuildSystemSections()); + append(BuildDiagnosticsSections()); + return sections; +} + +std::vector BuildAllCvarSections() { + std::map> category_items; + + if (!cvar::ConfigVars) { + return {}; + } + + for (const auto& [name, var] : *cvar::ConfigVars) { + if (var->is_transient() || var->category() == "Config") { + continue; + } + + IOSConfigItem item; + item.key = name; + item.title = var->display_name().empty() ? name : var->display_name(); + item.subtitle = var->description(); + item.category = var->category(); + item.is_advanced = var->is_advanced(); + + // Infer control type via dynamic_cast cascade. + if (auto* bv = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kToggle; + IOSConfigParseBoolString(TrimAscii(var->config_value()), &item.bool_value); + } else if (auto* iv = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kInteger; + int64_t parsed = 0; + if (IOSConfigParseInt64String(TrimAscii(var->config_value()), &parsed)) { + item.integer_value = parsed; + } + } else if (auto* uv = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kInteger; + int64_t parsed = 0; + if (IOSConfigParseInt64String(TrimAscii(var->config_value()), &parsed)) { + item.integer_value = parsed; + } + } else if (auto* u64v = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kInteger; + int64_t parsed = 0; + if (IOSConfigParseInt64String(TrimAscii(var->config_value()), &parsed)) { + item.integer_value = parsed; + } + } else if (auto* i64v = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kInteger; + int64_t parsed = 0; + if (IOSConfigParseInt64String(TrimAscii(var->config_value()), &parsed)) { + item.integer_value = parsed; + } + } else if (auto* dv = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kDouble; + std::string raw = TrimAscii(var->config_value()); + if (!raw.empty()) { + char* end = nullptr; + double parsed = std::strtod(raw.c_str(), &end); + if (end != raw.c_str() && *end == '\0') { + item.double_value = parsed; + } + } + } else if (auto* fv = dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kDouble; + std::string raw = TrimAscii(var->config_value()); + if (!raw.empty()) { + char* end = nullptr; + double parsed = std::strtod(raw.c_str(), &end); + if (end != raw.c_str() && *end == '\0') { + item.double_value = parsed; + } + } + } else if (auto* sv = dynamic_cast*>(var)) { + const auto& enum_opts = xe::ui::GetKnownEnumOptions(); + auto enum_it = enum_opts.find(name); + if (enum_it != enum_opts.end() && !enum_it->second.empty()) { + item.control_type = IOSConfigControlType::kEnum; + item.enum_key = name; + item.string_value = TrimAscii(var->config_value()); + for (size_t i = 0; i < enum_it->second.size(); ++i) { + item.choices.push_back({enum_it->second[i], static_cast(i)}); + item.choice_string_values.push_back(enum_it->second[i]); + if (enum_it->second[i] == item.string_value) { + item.choice_value = static_cast(i); + } + } + } else { + item.control_type = IOSConfigControlType::kString; + item.string_value = TrimAscii(var->config_value()); + } + } else if (dynamic_cast*>(var)) { + item.control_type = IOSConfigControlType::kPath; + item.string_value = TrimAscii(var->config_value()); + } else { + // Unknown type — fall back to string display. + item.control_type = IOSConfigControlType::kString; + item.string_value = TrimAscii(var->config_value()); + } + + category_items[item.category].push_back(std::move(item)); + } + + std::vector sections; + for (auto& [category, items] : category_items) { + std::sort(items.begin(), items.end(), + [](const IOSConfigItem& a, const IOSConfigItem& b) { + return a.key < b.key; + }); + IOSConfigSection section; + section.title = category; + section.items = std::move(items); + sections.push_back(std::move(section)); + } + std::sort(sections.begin(), sections.end(), + [](const IOSConfigSection& a, const IOSConfigSection& b) { + return a.title < b.title; + }); + + return sections; +} + +static const IOSConfigSection* FindSection( + const std::vector& sections, const std::string& title) { + auto it = std::find_if(sections.begin(), sections.end(), + [&](const IOSConfigSection& section) { + return section.title == title; + }); + return it == sections.end() ? nullptr : &*it; +} + +static IOSConfigSection FilterSectionByKeys(const IOSConfigSection& source, + std::vector keys) { + IOSConfigSection section; + section.title = source.title; + section.footer = source.footer; + for (const IOSConfigItem& item : source.items) { + if (std::find(keys.begin(), keys.end(), item.key) != keys.end()) { + section.items.push_back(item); + } + } + return section; +} + +static void PushFilteredSection(std::vector& sections, + const std::vector& all_sections, + const std::string& title, + std::vector keys) { + const IOSConfigSection* source = FindSection(all_sections, title); + if (!source) { + return; + } + PushIfNotEmpty(sections, FilterSectionByKeys(*source, std::move(keys))); +} + +static void PushWholeSection(std::vector& sections, + const std::vector& all_sections, + const std::string& title) { + const IOSConfigSection* source = FindSection(all_sections, title); + if (!source) { + return; + } + PushIfNotEmpty(sections, *source); +} + +std::string IOSConfigCatalogTitle(IOSConfigCatalogKind kind) { + switch (kind) { + case IOSConfigCatalogKind::kDisplay: + return "Display"; + case IOSConfigCatalogKind::kGraphics: + return "Graphics"; + case IOSConfigCatalogKind::kAudio: + return "Audio"; + case IOSConfigCatalogKind::kControls: + return "Touch Behavior"; + case IOSConfigCatalogKind::kPerformance: + return "Performance"; + case IOSConfigCatalogKind::kCompatibility: + return "Compatibility"; + case IOSConfigCatalogKind::kAdvanced: + case IOSConfigCatalogKind::kDebugSettings: + return "Advanced Debug"; + case IOSConfigCatalogKind::kDiagnostics: + return "Diagnostics"; + case IOSConfigCatalogKind::kSystem: + return "Automation"; + case IOSConfigCatalogKind::kPerGame: + return "Game Settings"; + case IOSConfigCatalogKind::kGraphicsCompat: + return "Graphics Compatibility"; + case IOSConfigCatalogKind::kAllCvars: + return "All Config Settings"; + case IOSConfigCatalogKind::kMain: + default: + return "Settings"; + } +} + +std::vector BuildIOSConfigSectionsForKind(IOSConfigCatalogKind kind) { + std::vector all_sections = BuildAllIOSConfigSections(); + std::vector sections; + + switch (kind) { + case IOSConfigCatalogKind::kDisplay: + return BuildDisplaySections(); + + case IOSConfigCatalogKind::kGraphics: + return BuildGraphicsSections(); + + case IOSConfigCatalogKind::kAudio: + return BuildAudioSections(); + + case IOSConfigCatalogKind::kControls: + return BuildControlsSections(); + + case IOSConfigCatalogKind::kPerformance: + return BuildPerformanceSections(); + + case IOSConfigCatalogKind::kCompatibility: + return BuildCompatibilitySections(); + + case IOSConfigCatalogKind::kAdvanced: + case IOSConfigCatalogKind::kDebugSettings: + return BuildDebugSettingsSections(); + + case IOSConfigCatalogKind::kDiagnostics: + return BuildDiagnosticsSections(); + + case IOSConfigCatalogKind::kSystem: + return BuildSystemSections(); + + case IOSConfigCatalogKind::kPerGame: + PushFilteredSection(sections, all_sections, "Display", + {"present_letterbox", "guest_display_refresh_cap", "use_50Hz_mode"}); + PushWholeSection(sections, all_sections, "GPU Workarounds"); + PushWholeSection(sections, all_sections, "Memory & Boot"); + PushWholeSection(sections, all_sections, "JIT & Codegen"); + { + IOSConfigSection more; + more.title = "More Overrides"; + AddActionSetting(more.items, IOSConfigAction::kOpenAllConfigSettings, + "Search All Config", + "Find any non-transient cvar and save it as a title-specific override."); + PushIfNotEmpty(sections, std::move(more)); + } + return sections; + + case IOSConfigCatalogKind::kGraphicsCompat: + return BuildGraphicsCompatSections(); + + case IOSConfigCatalogKind::kAllCvars: + return BuildAllCvarSections(); + + case IOSConfigCatalogKind::kMain: + default: + PushFilteredSection(sections, all_sections, "Display", + {"present_letterbox", "framerate_limit", + "guest_display_refresh_cap"}); + PushFilteredSection(sections, all_sections, "Audio", + {"mute"}); + PushWholeSection(sections, all_sections, "Automation"); + return sections; + } +} + +std::vector BuildIOSConfigSections() { + return BuildIOSConfigSectionsForKind(IOSConfigCatalogKind::kMain); +} diff --git a/src/xenia/ui/ios/settings/ios_config_models.h b/src/xenia/ui/ios/settings/ios_config_models.h new file mode 100644 index 000000000..7777490a6 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_models.h @@ -0,0 +1,99 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONFIG_MODELS_H_ +#define XENIA_UI_IOS_CONFIG_MODELS_H_ + +#include +#include +#include + +// Plain-data view models that the iOS settings sheet +// (XeniaConfigViewController) builds at runtime from xenia cvars and +// NSUserDefaults, and that the choice list sheet +// (XeniaChoiceListViewController) consumes when picking a value. +// +// Kept here in one header so the two sheets and any future config-related +// view controllers reference the same type definitions. + +enum class IOSConfigControlType { + kToggle, + kChoiceInt32, + kChoiceUInt32, + kChoiceUInt64, + kChoiceString, + kAction, + kInteger, + kDouble, + kString, + kPath, + kEnum, +}; + +enum class IOSConfigStorage { + kConfigVar, + kUserDefaults, +}; + +enum class IOSConfigCatalogKind { + kMain, + kDisplay, + kGraphics, + kAudio, + kControls, + kPerformance, + kCompatibility, + kAdvanced, + kDebugSettings, + kDiagnostics, + kSystem, + kPerGame, + kGraphicsCompat, + kAllCvars, +}; + +enum class IOSConfigAction { + kNone, + kOpenAdvancedSettings, + kOpenDiagnosticsSettings, + kOpenAllConfigSettings, + kViewRecentLog, +}; + +struct IOSConfigChoice { + std::string title; + int64_t value = 0; +}; + +struct IOSConfigItem { + std::string key; + std::string title; + std::string subtitle; + IOSConfigControlType control_type = IOSConfigControlType::kToggle; + IOSConfigStorage storage = IOSConfigStorage::kConfigVar; + bool bool_value = false; + int64_t choice_value = 0; + int64_t integer_value = 0; + double double_value = 0.0; + std::string string_value; + IOSConfigAction action = IOSConfigAction::kNone; + std::vector choices; + std::vector choice_string_values; + std::string category; + bool is_advanced = false; + std::string enum_key; +}; + +struct IOSConfigSection { + std::string title; + std::string footer; + std::vector items; +}; + +#endif // XENIA_UI_IOS_CONFIG_MODELS_H_ diff --git a/src/xenia/ui/ios/settings/ios_config_storage.h b/src/xenia/ui/ios/settings/ios_config_storage.h new file mode 100644 index 000000000..266a2e253 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_storage.h @@ -0,0 +1,50 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONFIG_STORAGE_H_ +#define XENIA_UI_IOS_CONFIG_STORAGE_H_ + +#import + +#include +#include +#include +#include +#include + +#include "xenia/ui/ios/settings/ios_config_models.h" + +extern NSString* const kXeniaAutoOpenStikDebugOnLaunchPreferenceKey; +extern NSString* const kXeniaLastAutoStikDebugAttemptTimestampPreferenceKey; + +bool ApplyIOSConfigSections(const std::vector& sections); +void OverlayIOSConfigSectionsFromGameConfig(std::vector* sections, + uint32_t title_id); +bool ApplyIOSConfigSectionsToGameConfig(const std::vector& sections, + uint32_t title_id, const std::set& dirty_keys); + +// NSUserDefaults helpers re-used by the launcher main view controller. +bool GetUserDefaultBool(NSString* key, bool fallback); +double GetUserDefaultDouble(NSString* key, double fallback); +void SetUserDefaultDouble(NSString* key, double value); + +// Pending external-launch path persistence: when the launcher hands off to +// StikDebug to enable JIT, the path of the game that triggered the launch is +// persisted here so that on the way back in we can resume it. +void ClearPendingExternalLaunchPathPreference(void); +void StorePendingExternalLaunchPathPreference(const std::filesystem::path& path); +std::filesystem::path TakePendingExternalLaunchPathPreference(void); + +// Storage helpers used by the settings catalog builder. +bool IOSConfigHasConfigVar(const std::string& key); +std::string IOSConfigGetConfigVarString(const std::string& key, const std::string& fallback); +bool IOSConfigParseBoolString(const std::string& text, bool* value_out); +bool IOSConfigParseInt64String(const std::string& text, int64_t* value_out); + +#endif // XENIA_UI_IOS_CONFIG_STORAGE_H_ diff --git a/src/xenia/ui/ios/settings/ios_config_storage.mm b/src/xenia/ui/ios/settings/ios_config_storage.mm new file mode 100644 index 000000000..50ac33d0c --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_storage.mm @@ -0,0 +1,624 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_config_storage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/logging.h" +#include "xenia/config.h" + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +std::string TrimAscii(std::string value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + ++start; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +NSTimeInterval GetUnixTimeSeconds() { return [[NSDate date] timeIntervalSince1970]; } + +NSString* const kXeniaPendingExternalLaunchPathPreferenceKey = @"ios_pending_external_launch_path"; +NSString* const kXeniaPendingExternalLaunchTimestampPreferenceKey = + @"ios_pending_external_launch_timestamp"; + +constexpr NSTimeInterval kXeniaPendingExternalLaunchTTLSeconds = 120.0; + +} // namespace + +NSString* const kXeniaAutoOpenStikDebugOnLaunchPreferenceKey = @"ios_auto_open_stikdebug_on_launch"; +NSString* const kXeniaLastAutoStikDebugAttemptTimestampPreferenceKey = + @"ios_last_auto_stikdebug_attempt_timestamp"; + +static cvar::IConfigVar* GetConfigVar(const std::string& key) { + if (!cvar::ConfigVars) { + return nullptr; + } + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) { + return nullptr; + } + return it->second; +} + +bool IOSConfigHasConfigVar(const std::string& key) { return GetConfigVar(key) != nullptr; } + +std::string IOSConfigGetConfigVarString(const std::string& key, const std::string& fallback) { + cvar::IConfigVar* var = GetConfigVar(key); + if (!var) { + return fallback; + } + return TrimAscii(var->config_value()); +} + +bool IOSConfigParseBoolString(const std::string& text, bool* value_out) { + if (!value_out) { + return false; + } + std::string lower = text; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (lower == "true" || lower == "1") { + *value_out = true; + return true; + } + if (lower == "false" || lower == "0") { + *value_out = false; + return true; + } + return false; +} + +bool IOSConfigParseInt64String(const std::string& text, int64_t* value_out) { + if (!value_out) { + return false; + } + char* end = nullptr; + errno = 0; + long long parsed = std::strtoll(text.c_str(), &end, 10); + if (errno != 0 || !end || *end != '\0') { + return false; + } + *value_out = static_cast(parsed); + return true; +} + +std::string StripTomlQuotes(std::string value) { + value = TrimAscii(std::move(value)); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + return value; +} + +double ParseDoubleFallback(const std::string& text, double fallback) { + if (text.empty()) { + return fallback; + } + char* end = nullptr; + double parsed = std::strtod(text.c_str(), &end); + if (end != text.c_str() && *end == '\0') { + return parsed; + } + return fallback; +} + +void SetChoiceValueForInteger(IOSConfigItem* item, int64_t value) { + item->choice_value = value; + for (const IOSConfigChoice& choice : item->choices) { + if (choice.value == value) { + item->choice_value = choice.value; + return; + } + } +} + +void SetChoiceValueForString(IOSConfigItem* item, const std::string& value) { + item->string_value = value; + for (size_t i = 0; i < item->choice_string_values.size(); ++i) { + if (item->choice_string_values[i] == value) { + item->choice_value = static_cast(i); + return; + } + } +} + +bool ApplyTomlNodeToConfigItem(const toml::node* node, IOSConfigItem* item) { + if (!node || !item || item->key.empty()) { + return false; + } + + cvar::IConfigVar* var = GetConfigVar(item->key); + if (!var) { + return false; + } + + void* saved_state = var->SaveConfigValueState(); + var->LoadConfigValue(node); + const std::string normalized_value = StripTomlQuotes(var->config_value()); + var->RestoreConfigValueState(saved_state); + + switch (item->control_type) { + case IOSConfigControlType::kToggle: { + bool parsed = false; + if (IOSConfigParseBoolString(normalized_value, &parsed)) { + item->bool_value = parsed; + return true; + } + return false; + } + case IOSConfigControlType::kChoiceInt32: + case IOSConfigControlType::kChoiceUInt32: + case IOSConfigControlType::kChoiceUInt64: + case IOSConfigControlType::kInteger: { + int64_t parsed = 0; + if (!IOSConfigParseInt64String(normalized_value, &parsed)) { + return false; + } + if (item->control_type == IOSConfigControlType::kInteger) { + item->integer_value = parsed; + } else { + SetChoiceValueForInteger(item, parsed); + } + return true; + } + case IOSConfigControlType::kDouble: + item->double_value = ParseDoubleFallback(normalized_value, item->double_value); + return true; + case IOSConfigControlType::kChoiceString: + case IOSConfigControlType::kEnum: + SetChoiceValueForString(item, normalized_value); + return true; + case IOSConfigControlType::kString: + case IOSConfigControlType::kPath: + item->string_value = normalized_value; + return true; + case IOSConfigControlType::kAction: + return false; + } +} + +toml::table* EnsureGameConfigCategoryTable(toml::table* config_table, + const std::string& category) { + if (!config_table || category.empty()) { + return nullptr; + } + if (!config_table->contains(category)) { + config_table->insert(category, toml::table{}); + } + toml::table* category_table = (*config_table)[category].as_table(); + if (!category_table) { + config_table->insert_or_assign(category, toml::table{}); + category_table = (*config_table)[category].as_table(); + } + return category_table; +} + +int64_t IntegerValueForConfigItem(const IOSConfigItem& item) { + switch (item.control_type) { + case IOSConfigControlType::kChoiceInt32: + case IOSConfigControlType::kChoiceUInt32: + case IOSConfigControlType::kChoiceUInt64: + return item.choice_value; + case IOSConfigControlType::kInteger: + return item.integer_value; + default: + return 0; + } +} + +std::string StringValueForConfigItem(const IOSConfigItem& item) { + return StripTomlQuotes(item.string_value); +} + +bool InsertGameConfigValue(toml::table* config_table, const IOSConfigItem& item) { + if (!config_table || item.storage != IOSConfigStorage::kConfigVar || item.key.empty() || + item.control_type == IOSConfigControlType::kAction) { + return true; + } + + cvar::IConfigVar* var = GetConfigVar(item.key); + if (!var || var->is_transient()) { + XELOGW("iOS settings: cannot save game override for '{}'", item.key); + return false; + } + + toml::table* category_table = EnsureGameConfigCategoryTable(config_table, var->category()); + if (!category_table) { + return false; + } + const std::string& name = var->name(); + + if (dynamic_cast*>(var)) { + category_table->insert_or_assign(name, item.bool_value); + return true; + } + if (dynamic_cast*>(var)) { + category_table->insert_or_assign(name, static_cast(IntegerValueForConfigItem(item))); + return true; + } + if (dynamic_cast*>(var)) { + int64_t value = IntegerValueForConfigItem(item); + if (value < 0 || value > std::numeric_limits::max()) { + XELOGW("iOS settings: uint32 game override out of range for '{}'", item.key); + return false; + } + category_table->insert_or_assign(name, static_cast(value)); + return true; + } + if (dynamic_cast*>(var)) { + category_table->insert_or_assign(name, IntegerValueForConfigItem(item)); + return true; + } + if (dynamic_cast*>(var)) { + int64_t value = IntegerValueForConfigItem(item); + if (value < 0) { + XELOGW("iOS settings: uint64 game override out of range for '{}'", item.key); + return false; + } + category_table->insert_or_assign(name, value); + return true; + } + if (dynamic_cast*>(var)) { + category_table->insert_or_assign(name, item.double_value); + return true; + } + if (dynamic_cast*>(var)) { + category_table->insert_or_assign(name, static_cast(item.double_value)); + return true; + } + if (dynamic_cast*>(var) || + dynamic_cast*>(var)) { + category_table->insert_or_assign(name, StringValueForConfigItem(item)); + return true; + } + + XELOGW("iOS settings: unsupported game override type for '{}'", item.key); + return false; +} + +static NSUserDefaults* GetUserDefaults() { return [NSUserDefaults standardUserDefaults]; } + +bool GetUserDefaultBool(NSString* key, bool fallback) { + if (!key || key.length == 0) { + return fallback; + } + if (![GetUserDefaults() objectForKey:key]) { + return fallback; + } + return [GetUserDefaults() boolForKey:key]; +} + +double GetUserDefaultDouble(NSString* key, double fallback) { + if (!key || key.length == 0) { + return fallback; + } + if (![GetUserDefaults() objectForKey:key]) { + return fallback; + } + return [GetUserDefaults() doubleForKey:key]; +} + +static NSString* GetUserDefaultString(NSString* key) { + if (!key || key.length == 0) { + return nil; + } + return [GetUserDefaults() stringForKey:key]; +} + +static void SetUserDefaultBool(NSString* key, bool value) { + if (!key || key.length == 0) { + return; + } + [GetUserDefaults() setBool:value forKey:key]; +} + +void SetUserDefaultDouble(NSString* key, double value) { + if (!key || key.length == 0) { + return; + } + [GetUserDefaults() setDouble:value forKey:key]; +} + +static void SetUserDefaultString(NSString* key, NSString* value) { + if (!key || key.length == 0) { + return; + } + if (value.length > 0) { + [GetUserDefaults() setObject:value forKey:key]; + } else { + [GetUserDefaults() removeObjectForKey:key]; + } +} + +void ClearPendingExternalLaunchPathPreference() { + [GetUserDefaults() removeObjectForKey:kXeniaPendingExternalLaunchPathPreferenceKey]; + [GetUserDefaults() removeObjectForKey:kXeniaPendingExternalLaunchTimestampPreferenceKey]; +} + +void StorePendingExternalLaunchPathPreference(const std::filesystem::path& path) { + NSString* path_string = ToNSString(path.string()); + if (!path_string || path_string.length == 0) { + ClearPendingExternalLaunchPathPreference(); + return; + } + SetUserDefaultString(kXeniaPendingExternalLaunchPathPreferenceKey, path_string); + SetUserDefaultDouble(kXeniaPendingExternalLaunchTimestampPreferenceKey, GetUnixTimeSeconds()); +} + +std::filesystem::path TakePendingExternalLaunchPathPreference() { + NSString* path_string = GetUserDefaultString(kXeniaPendingExternalLaunchPathPreferenceKey); + const double stored_at = + GetUserDefaultDouble(kXeniaPendingExternalLaunchTimestampPreferenceKey, 0.0); + ClearPendingExternalLaunchPathPreference(); + if (!path_string || path_string.length == 0 || stored_at <= 0.0) { + return std::filesystem::path(); + } + if ((GetUnixTimeSeconds() - stored_at) > kXeniaPendingExternalLaunchTTLSeconds) { + XELOGW("iOS: Discarding stale deferred external launch request"); + return std::filesystem::path(); + } + return std::filesystem::path([path_string UTF8String]).lexically_normal(); +} + +template +static bool SetConfigVarValue(const std::string& key, T value) { + cvar::IConfigVar* var = GetConfigVar(key); + if (!var) { + XELOGW("iOS settings: missing config var '{}'", key); + return false; + } + toml::value node(value); + var->LoadConfigValue(&node); + return true; +} + +template +static bool LoadConfigVarValue(cvar::IConfigVar* var, T value) { + toml::value node(value); + var->LoadConfigValue(&node); + return true; +} + +static bool SetConfigVarIntegerValue(const std::string& key, int64_t value) { + cvar::IConfigVar* var = GetConfigVar(key); + if (!var) { + XELOGW("iOS settings: missing config var '{}'", key); + return false; + } + if (dynamic_cast*>(var)) { + if (value < std::numeric_limits::min() || + value > std::numeric_limits::max()) { + XELOGW("iOS settings: integer value {} out of int32 range for '{}'", value, key); + return false; + } + return LoadConfigVarValue(var, static_cast(value)); + } + if (dynamic_cast*>(var)) { + if (value < 0 || value > std::numeric_limits::max()) { + XELOGW("iOS settings: integer value {} out of uint32 range for '{}'", value, key); + return false; + } + return LoadConfigVarValue(var, static_cast(value)); + } + if (dynamic_cast*>(var)) { + return LoadConfigVarValue(var, value); + } + if (dynamic_cast*>(var)) { + if (value < 0) { + XELOGW("iOS settings: negative value {} for uint64 config var '{}'", value, key); + return false; + } + return LoadConfigVarValue(var, static_cast(value)); + } + XELOGW("iOS settings: config var '{}' is not an integer type", key); + return false; +} + +static bool SetConfigVarFloatingValue(const std::string& key, double value) { + cvar::IConfigVar* var = GetConfigVar(key); + if (!var) { + XELOGW("iOS settings: missing config var '{}'", key); + return false; + } + if (dynamic_cast*>(var)) { + return LoadConfigVarValue(var, value); + } + if (dynamic_cast*>(var)) { + return LoadConfigVarValue(var, static_cast(value)); + } + XELOGW("iOS settings: config var '{}' is not a floating-point type", key); + return false; +} + +static bool SetConfigVarStringLikeValue(const std::string& key, const std::string& value) { + cvar::IConfigVar* var = GetConfigVar(key); + if (!var) { + XELOGW("iOS settings: missing config var '{}'", key); + return false; + } + if (dynamic_cast*>(var) || + dynamic_cast*>(var)) { + return LoadConfigVarValue(var, value); + } + XELOGW("iOS settings: config var '{}' is not a string/path type", key); + return false; +} + +bool ApplyIOSConfigSections(const std::vector& sections) { + bool ok = true; + for (const IOSConfigSection& section : sections) { + for (const IOSConfigItem& item : section.items) { + switch (item.control_type) { + case IOSConfigControlType::kToggle: + if (item.storage == IOSConfigStorage::kUserDefaults) { + SetUserDefaultBool(ToNSString(item.key), item.bool_value); + } else { + ok &= SetConfigVarValue(item.key, item.bool_value); + } + break; + case IOSConfigControlType::kChoiceInt32: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported integer storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarValue(item.key, static_cast(item.choice_value)); + break; + case IOSConfigControlType::kChoiceUInt32: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported uint32 storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarValue(item.key, static_cast(item.choice_value)); + break; + case IOSConfigControlType::kChoiceUInt64: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported uint64 storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarValue(item.key, static_cast(item.choice_value)); + break; + case IOSConfigControlType::kChoiceString: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported string storage for '{}'", item.key); + ok = false; + break; + } + if (item.choice_value < 0 || + item.choice_value >= static_cast(item.choice_string_values.size())) { + XELOGW("iOS settings: invalid string choice index {} for '{}'", item.choice_value, + item.key); + ok = false; + break; + } + ok &= SetConfigVarValue( + item.key, item.choice_string_values[static_cast(item.choice_value)]); + break; + case IOSConfigControlType::kAction: + break; + case IOSConfigControlType::kInteger: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported integer storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarIntegerValue(item.key, item.integer_value); + break; + case IOSConfigControlType::kDouble: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported double storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarFloatingValue(item.key, item.double_value); + break; + case IOSConfigControlType::kString: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported string storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarStringLikeValue(item.key, item.string_value); + break; + case IOSConfigControlType::kPath: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported path storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarStringLikeValue(item.key, item.string_value); + break; + case IOSConfigControlType::kEnum: + if (item.storage != IOSConfigStorage::kConfigVar) { + XELOGW("iOS settings: unsupported enum storage for '{}'", item.key); + ok = false; + break; + } + ok &= SetConfigVarStringLikeValue(item.key, item.string_value); + break; + } + } + } + config::SaveConfig(); + return ok; +} + +void OverlayIOSConfigSectionsFromGameConfig(std::vector* sections, + uint32_t title_id) { + if (!sections || !title_id) { + return; + } + + toml::table game_config = config::LoadGameConfig(title_id); + for (IOSConfigSection& section : *sections) { + for (IOSConfigItem& item : section.items) { + if (item.storage != IOSConfigStorage::kConfigVar || item.key.empty()) { + continue; + } + cvar::IConfigVar* var = GetConfigVar(item.key); + if (!var) { + continue; + } + toml::path config_key = toml::path(var->category() + "." + var->name()); + const auto config_key_node = game_config.at_path(config_key); + if (config_key_node) { + ApplyTomlNodeToConfigItem(config_key_node.node(), &item); + } + } + } +} + +bool ApplyIOSConfigSectionsToGameConfig(const std::vector& sections, + uint32_t title_id, + const std::set& dirty_keys) { + if (!title_id) { + return false; + } + + bool ok = true; + toml::table game_config = config::LoadGameConfig(title_id); + for (const IOSConfigSection& section : sections) { + for (const IOSConfigItem& item : section.items) { + if (dirty_keys.find(item.key) == dirty_keys.end()) { + continue; + } + ok &= InsertGameConfigValue(&game_config, item); + } + } + + try { + config::SaveGameConfig(title_id, game_config); + } catch (const std::exception& e) { + XELOGE("iOS settings: failed to save game config for title {:08X}: {}", title_id, e.what()); + return false; + } + return ok; +} diff --git a/src/xenia/ui/ios/settings/ios_config_view_controller.h b/src/xenia/ui/ios/settings/ios_config_view_controller.h new file mode 100644 index 000000000..6d35270b6 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_view_controller.h @@ -0,0 +1,45 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_CONFIG_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_CONFIG_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/settings/ios_config_models.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Settings sheet shown from the launcher and from the in-game overlay. Each +// row maps to either a Xenia cvar or an iOS NSUserDefaults key; rows are +// constructed by ios_config_builder, and Save persists every dirty row back +// through ApplyIOSConfigSections. +@interface XeniaConfigViewController : XESheetTableViewController +@property(nonatomic, copy) void (^dismissalHandler)(void); +@property(nonatomic, assign) BOOL showsRootDismissButton; +@property(nonatomic, readonly) IOSConfigCatalogKind catalogKind; +@property(nonatomic, readonly) uint32_t gameTitleID; +@property(nonatomic, copy, readonly) NSString* gameTitle; +@property(nonatomic, assign) BOOL liveOverride; + +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind style:(UITableViewStyle)style; +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + style:(UITableViewStyle)style + gameTitleID:(uint32_t)gameTitleID + gameTitle:(NSString*)gameTitle; + +// Replaces the section list and reloads the table. Intended for subclasses +// that need dynamic filtering (e.g. search controllers). +- (void)replaceSections:(std::vector)newSections; + +// Subclass hooks for dynamic catalogs and filtered save surfaces. +- (std::vector)buildSections; +- (std::vector)sectionsForSaving; +@end + +#endif // XENIA_UI_IOS_CONFIG_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_config_view_controller.mm b/src/xenia/ui/ios/settings/ios_config_view_controller.mm new file mode 100644 index 000000000..f6d79d451 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_config_view_controller.mm @@ -0,0 +1,1103 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_config_view_controller.h" + +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/logging.h" +#include "xenia/ui/config_helpers.h" + +#import "xenia/ui/ios/settings/ios_choice_list_view_controller.h" +#import "xenia/ui/ios/launcher/ios_compat_data.h" +#import "xenia/ui/ios/settings/ios_config_builder.h" +#import "xenia/ui/ios/settings/ios_config_models.h" +#import "xenia/ui/ios/app/ios_landscape_navigation_controller.h" +#import "xenia/ui/ios/settings/ios_debug_settings_view_controller.h" +#import "xenia/ui/ios/settings/ios_log_view_controller.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +NSString* const kXeniOSWebsiteURL = @"https://xenios.jp"; +NSString* const kXeniOSDiscordURL = @"https://discord.gg/QwcTtNKTGf"; +NSString* const kXeniOSGitHubURL = @"https://github.com/xenios-jp/XeniOS"; +NSString* const kXeniOSKoFiURL = @"https://ko-fi.com/xenios"; + +typedef NS_ENUM(NSInteger, XeniaConfigFooterLinkTag) { + kXeniaConfigFooterLinkWebsite = 1, + kXeniaConfigFooterLinkGitHub = 2, + kXeniaConfigFooterLinkDiscord = 3, + kXeniaConfigFooterLinkKoFi = 4, +}; + +void OpenExternalURLString(NSString* url_string) { + if (!url_string || url_string.length == 0) { + return; + } + NSURL* url = [NSURL URLWithString:url_string]; + if (!url) { + XELOGW("iOS: Failed to create URL from string: {}", + url_string ? [url_string UTF8String] : "(null)"); + return; + } + [[UIApplication sharedApplication] openURL:url + options:@{} + completionHandler:^(BOOL success) { + if (!success) { + XELOGW("iOS: Failed to open external URL: {}", [url_string UTF8String]); + } + }]; +} + +bool OverrideCvarByName(const std::string& key, bool value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} +bool OverrideCvarByName(const std::string& key, int32_t value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} +bool OverrideCvarByName(const std::string& key, int64_t value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} +bool OverrideCvarByName(const std::string& key, double value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} +bool OverrideCvarByName(const std::string& key, const std::string& value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} +bool OverrideCvarByName(const std::string& key, const std::filesystem::path& value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + auto* cv = dynamic_cast*>(it->second); + if (!cv) return false; + cv->OverrideConfigValue(value); + return true; +} + +bool OverrideIntegerCvarByName(const std::string& key, int64_t value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + cvar::IConfigVar* var = it->second; + if (auto* cv = dynamic_cast*>(var)) { + if (value < std::numeric_limits::min() || + value > std::numeric_limits::max()) { + return false; + } + cv->OverrideConfigValue(static_cast(value)); + return true; + } + if (auto* cv = dynamic_cast*>(var)) { + if (value < 0 || value > std::numeric_limits::max()) { + return false; + } + cv->OverrideConfigValue(static_cast(value)); + return true; + } + if (auto* cv = dynamic_cast*>(var)) { + cv->OverrideConfigValue(value); + return true; + } + if (auto* cv = dynamic_cast*>(var)) { + if (value < 0) { + return false; + } + cv->OverrideConfigValue(static_cast(value)); + return true; + } + return false; +} + +bool OverrideFloatingCvarByName(const std::string& key, double value) { + if (!cvar::ConfigVars) return false; + auto it = cvar::ConfigVars->find(key); + if (it == cvar::ConfigVars->end()) return false; + cvar::IConfigVar* var = it->second; + if (auto* cv = dynamic_cast*>(var)) { + cv->OverrideConfigValue(value); + return true; + } + if (auto* cv = dynamic_cast*>(var)) { + cv->OverrideConfigValue(static_cast(value)); + return true; + } + return false; +} + +bool OverrideStringLikeCvarByName(const std::string& key, const std::string& value) { + if (OverrideCvarByName(key, value)) { + return true; + } + return OverrideCvarByName(key, std::filesystem::path(value)); +} + +} // namespace + +@implementation XeniaConfigViewController { + IOSConfigCatalogKind catalog_kind_; + std::vector sections_; + std::set dirty_keys_; + BOOL hasPendingChanges_; + BOOL shows_root_dismiss_button_; + BOOL live_override_; + uint32_t game_title_id_; + NSString* game_title_; + UIBarButtonItem* saveButton_; + void (^dismissal_handler_)(void); +} + +@synthesize dismissalHandler = dismissal_handler_; +@synthesize showsRootDismissButton = shows_root_dismiss_button_; +@synthesize liveOverride = live_override_; + +- (instancetype)initWithStyle:(UITableViewStyle)style { + return [self initWithCatalogKind:IOSConfigCatalogKind::kMain style:style]; +} + +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + style:(UITableViewStyle)style { + return [self initWithCatalogKind:catalogKind style:style gameTitleID:0 gameTitle:nil]; +} + +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + style:(UITableViewStyle)style + gameTitleID:(uint32_t)gameTitleID + gameTitle:(NSString*)gameTitle { + self = [super initWithStyle:style]; + if (self) { + catalog_kind_ = catalogKind; + game_title_id_ = gameTitleID; + game_title_ = [gameTitle copy]; + shows_root_dismiss_button_ = YES; + } + return self; +} + +- (IOSConfigCatalogKind)catalogKind { + return catalog_kind_; +} + +- (uint32_t)gameTitleID { + return game_title_id_; +} + +- (NSString*)gameTitle { + return game_title_; +} + +- (std::vector)buildSections { + std::vector sections = BuildIOSConfigSectionsForKind(catalog_kind_); + if (game_title_id_) { + OverlayIOSConfigSectionsFromGameConfig(§ions, game_title_id_); + } + return sections; +} + +- (std::vector)sectionsForSaving { + return sections_; +} + +- (void)replaceSections:(std::vector)newSections { + sections_ = std::move(newSections); + [self.tableView reloadData]; +} + +- (void)dealloc { + [game_title_ release]; + [saveButton_ release]; + [dismissal_handler_ release]; + [super dealloc]; +} + +- (UIView*)restartNoticeHeaderView { + UIView* container = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 136)] autorelease]; + container.backgroundColor = [UIColor clearColor]; + + UIView* card = [[[UIView alloc] init] autorelease]; + card.translatesAutoresizingMaskIntoConstraints = NO; + card.backgroundColor = [XeniaTheme bgSurface]; + card.layer.cornerRadius = 12.0; + card.layer.borderWidth = 0.5; + card.layer.borderColor = [XeniaTheme border].CGColor; + [container addSubview:card]; + + UILabel* title = [[[UILabel alloc] init] autorelease]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.backgroundColor = [UIColor clearColor]; + title.text = game_title_id_ ? @"Game-Specific Settings" : @"Change Settings Before Launch"; + title.textColor = [XeniaTheme textPrimary]; + title.numberOfLines = 0; + xe_apply_label_font(title, UIFontTextStyleHeadline, 17.0, UIFontWeightSemibold); + [card addSubview:title]; + + UILabel* body = [[[UILabel alloc] init] autorelease]; + body.translatesAutoresizingMaskIntoConstraints = NO; + body.backgroundColor = [UIColor clearColor]; + if (game_title_id_) { + NSString* title_id = XEFormatTitleIDHexUpper(game_title_id_); + NSString* game_name = game_title_.length ? game_title_ : @"this game"; + body.text = [NSString stringWithFormat: + @"Overrides here apply only to %@ (%@). They are read when the title " + @"launches, so relaunch the game after saving.", + game_name, title_id]; + } else { + body.text = @"To avoid partial updates, change settings before launching a game. If " + @"you save changes while a game is already running, fully relaunch " + @"XeniOS before testing them."; + } + body.textColor = [XeniaTheme textSecondary]; + body.numberOfLines = 0; + xe_apply_label_font(body, UIFontTextStyleBody, 17.0, UIFontWeightRegular); + [card addSubview:body]; + + [NSLayoutConstraint activateConstraints:@[ + [card.topAnchor constraintEqualToAnchor:container.topAnchor constant:8.0], + [card.leadingAnchor constraintEqualToAnchor:container.leadingAnchor constant:16.0], + [card.trailingAnchor constraintEqualToAnchor:container.trailingAnchor constant:-16.0], + [card.bottomAnchor constraintEqualToAnchor:container.bottomAnchor constant:-4.0], + + [title.topAnchor constraintEqualToAnchor:card.topAnchor constant:14.0], + [title.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:14.0], + [title.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-14.0], + + [body.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:6.0], + [body.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:14.0], + [body.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-14.0], + [body.bottomAnchor constraintEqualToAnchor:card.bottomAnchor constant:-14.0], + ]]; + + return container; +} + +- (UIView*)liveOverrideHeaderView { + UIView* container = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 48)] autorelease]; + container.backgroundColor = [UIColor clearColor]; + + UILabel* label = [[[UILabel alloc] init] autorelease]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.backgroundColor = [UIColor clearColor]; + label.text = @"Changes take effect immediately"; + label.textAlignment = NSTextAlignmentCenter; + label.textColor = [XeniaTheme textMuted]; + label.numberOfLines = 1; + xe_apply_label_font(label, UIFontTextStyleCaption1, 12.0, UIFontWeightMedium); + [container addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [label.centerXAnchor constraintEqualToAnchor:container.centerXAnchor], + [label.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], + [label.leadingAnchor constraintGreaterThanOrEqualToAnchor:container.leadingAnchor constant:16.0], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:container.trailingAnchor constant:-16.0], + ]]; + + return container; +} + +- (void)updateTableAccessoryLayoutForView:(UIView*)view isHeader:(BOOL)isHeader { + if (!view) { + return; + } + CGFloat width = CGRectGetWidth(self.tableView.bounds); + if (width <= 0.0) { + width = CGRectGetWidth(self.view.bounds); + } + if (width <= 0.0 && self.navigationController) { + width = CGRectGetWidth(self.navigationController.view.bounds); + } + if (width <= 0.0) { + width = CGRectGetWidth(UIScreen.mainScreen.bounds); + } + if (width <= 0.0) { + return; + } + CGRect frame = view.frame; + frame.size.width = width; + view.frame = frame; + [view setNeedsLayout]; + [view layoutIfNeeded]; + CGSize fitting_size = + [view systemLayoutSizeFittingSize:CGSizeMake(width, UILayoutFittingCompressedSize.height) + withHorizontalFittingPriority:UILayoutPriorityRequired + verticalFittingPriority:UILayoutPriorityFittingSizeLevel]; + CGFloat target_height = ceil(fitting_size.height); + if (target_height <= 0.0) { + return; + } + if (fabs(CGRectGetHeight(view.frame) - target_height) <= 0.5 && + fabs(CGRectGetWidth(view.frame) - width) <= 0.5) { + return; + } + frame.size.height = target_height; + view.frame = frame; + if (isHeader) { + self.tableView.tableHeaderView = view; + } else { + self.tableView.tableFooterView = view; + } +} + +- (void)updateTableHeaderAndFooterLayout { + [self updateTableAccessoryLayoutForView:self.tableView.tableHeaderView isHeader:YES]; + [self updateTableAccessoryLayoutForView:self.tableView.tableFooterView isHeader:NO]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = ToNSString(IOSConfigCatalogTitle(catalog_kind_)); + if (game_title_id_ && game_title_.length) { + self.navigationItem.prompt = game_title_; + } + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 16, 0, 16); + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 132.0; + if (@available(iOS 15.0, *)) { + self.tableView.sectionHeaderTopPadding = 0; + } + sections_ = [self buildSections]; + hasPendingChanges_ = NO; + + if (live_override_) { + self.tableView.tableHeaderView = [self liveOverrideHeaderView]; + self.tableView.tableFooterView = nil; + [self updateTableHeaderAndFooterLayout]; + } else { + self.tableView.tableHeaderView = [self restartNoticeHeaderView]; + self.tableView.tableFooterView = [self versionFooterView]; + [self updateTableHeaderAndFooterLayout]; + } + + if (!live_override_) { + saveButton_ = [[UIBarButtonItem alloc] initWithTitle:@"Save" + style:UIBarButtonItemStyleDone + target:self + action:@selector(saveTapped:)]; + saveButton_.enabled = NO; + } + [self updateNavigationItems]; +} + +- (void)footerLinkTapped:(UIButton*)sender { + switch ((XeniaConfigFooterLinkTag)sender.tag) { + case kXeniaConfigFooterLinkWebsite: + OpenExternalURLString(kXeniOSWebsiteURL); + break; + case kXeniaConfigFooterLinkGitHub: + OpenExternalURLString(kXeniOSGitHubURL); + break; + case kXeniaConfigFooterLinkDiscord: + OpenExternalURLString(kXeniOSDiscordURL); + break; + case kXeniaConfigFooterLinkKoFi: + OpenExternalURLString(kXeniOSKoFiURL); + break; + default: + break; + } +} + +- (UIView*)versionFooterView { + NSString* footer_text = xe_user_facing_build_label(xe_current_compat_report_build_info()); + const BOOL has_footer_text = footer_text.length > 0; + NSString* memorial_text = @"XeniOS is one of several apps with dedication keeping the memory of " + @"\"Lily\" alive 11/03/2023"; + UIView* footer = + [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, has_footer_text ? 176 : 78)] autorelease]; + footer.backgroundColor = [UIColor clearColor]; + + UILabel* links_label = [[[UILabel alloc] init] autorelease]; + links_label.translatesAutoresizingMaskIntoConstraints = NO; + links_label.backgroundColor = [UIColor clearColor]; + links_label.text = @"Links"; + links_label.textAlignment = NSTextAlignmentCenter; + links_label.textColor = [XeniaTheme textMuted]; + links_label.numberOfLines = 1; + xe_apply_label_font(links_label, UIFontTextStyleCaption1, 12.0, UIFontWeightSemibold); + [footer addSubview:links_label]; + + UIStackView* links_row = [[[UIStackView alloc] initWithArrangedSubviews:@[ + xe_make_settings_footer_button(@"SettingsLinkWebsite", @"globe", @"Website", + kXeniaConfigFooterLinkWebsite, NO, self, + @selector(footerLinkTapped:)), + xe_make_settings_footer_button( + @"SettingsLinkGitHub", @"chevron.left.forwardslash.chevron.right", @"GitHub", + kXeniaConfigFooterLinkGitHub, NO, self, @selector(footerLinkTapped:)), + xe_make_settings_footer_button(@"SettingsLinkDiscord", @"bubble.left.and.bubble.right", + @"Discord", kXeniaConfigFooterLinkDiscord, NO, self, + @selector(footerLinkTapped:)), + xe_make_settings_footer_button(@"SettingsLinkKoFi", @"cup.and.saucer", @"Ko-fi", + kXeniaConfigFooterLinkKoFi, NO, self, + @selector(footerLinkTapped:)), + ]] autorelease]; + links_row.translatesAutoresizingMaskIntoConstraints = NO; + links_row.axis = UILayoutConstraintAxisHorizontal; + links_row.alignment = UIStackViewAlignmentCenter; + links_row.distribution = UIStackViewDistributionEqualCentering; + links_row.spacing = 22.0; + [footer addSubview:links_row]; + + UIView* build_separator = nil; + UILabel* build_label = nil; + UILabel* memorial_label = nil; + if (has_footer_text) { + build_separator = [[[UIView alloc] init] autorelease]; + build_separator.translatesAutoresizingMaskIntoConstraints = NO; + build_separator.backgroundColor = [XeniaTheme border]; + [footer addSubview:build_separator]; + + build_label = [[[UILabel alloc] init] autorelease]; + build_label.translatesAutoresizingMaskIntoConstraints = NO; + build_label.backgroundColor = [UIColor clearColor]; + build_label.text = footer_text; + build_label.textAlignment = NSTextAlignmentCenter; + build_label.textColor = [XeniaTheme textMuted]; + build_label.numberOfLines = 1; + xe_apply_label_font(build_label, UIFontTextStyleFootnote, 14.0, UIFontWeightMedium); + [footer addSubview:build_label]; + + memorial_label = [[[UILabel alloc] init] autorelease]; + memorial_label.translatesAutoresizingMaskIntoConstraints = NO; + memorial_label.backgroundColor = [UIColor clearColor]; + memorial_label.text = memorial_text; + memorial_label.textAlignment = NSTextAlignmentCenter; + memorial_label.textColor = [XeniaTheme textSecondary]; + memorial_label.numberOfLines = 0; + xe_apply_label_font(memorial_label, UIFontTextStyleCaption1, 12.0, UIFontWeightRegular); + [footer addSubview:memorial_label]; + } + + NSMutableArray* constraints = [NSMutableArray arrayWithArray:@[ + [links_label.topAnchor constraintEqualToAnchor:footer.topAnchor constant:6], + [links_label.leadingAnchor constraintEqualToAnchor:footer.leadingAnchor constant:24], + [links_label.trailingAnchor constraintEqualToAnchor:footer.trailingAnchor constant:-24], + [links_row.topAnchor constraintEqualToAnchor:links_label.bottomAnchor constant:8], + [links_row.leadingAnchor constraintGreaterThanOrEqualToAnchor:footer.leadingAnchor constant:24], + [links_row.trailingAnchor constraintLessThanOrEqualToAnchor:footer.trailingAnchor constant:-24], + [links_row.centerXAnchor constraintEqualToAnchor:footer.centerXAnchor], + ]]; + + if (has_footer_text) { + [constraints addObjectsFromArray:@[ + [build_separator.topAnchor constraintEqualToAnchor:links_row.bottomAnchor constant:16], + [build_separator.leadingAnchor constraintEqualToAnchor:footer.leadingAnchor constant:24], + [build_separator.trailingAnchor constraintEqualToAnchor:footer.trailingAnchor constant:-24], + [build_separator.heightAnchor constraintEqualToConstant:0.5], + [build_label.topAnchor constraintEqualToAnchor:build_separator.bottomAnchor constant:12], + [build_label.leadingAnchor constraintEqualToAnchor:footer.leadingAnchor constant:24], + [build_label.trailingAnchor constraintEqualToAnchor:footer.trailingAnchor constant:-24], + [memorial_label.topAnchor constraintEqualToAnchor:build_label.bottomAnchor constant:8], + [memorial_label.leadingAnchor constraintEqualToAnchor:footer.leadingAnchor constant:24], + [memorial_label.trailingAnchor constraintEqualToAnchor:footer.trailingAnchor constant:-24], + [memorial_label.bottomAnchor constraintEqualToAnchor:footer.bottomAnchor constant:-8], + ]]; + } else { + [constraints addObject:[links_row.bottomAnchor constraintEqualToAnchor:footer.bottomAnchor + constant:-10]]; + } + + [NSLayoutConstraint activateConstraints:constraints]; + return footer; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self updateTableHeaderAndFooterLayout]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self updateNavigationItems]; + [self updateTableHeaderAndFooterLayout]; +} + +- (void)viewSafeAreaInsetsDidChange { + [super viewSafeAreaInsetsDidChange]; + [self updateTableHeaderAndFooterLayout]; +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if ([self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + // Header card's CGColor border was captured against the prior style; the + // simplest live-refresh is to rebuild the table's accessory views so the + // dynamic XeniaTheme accessors resolve against the new trait collection. + if (live_override_) { + self.tableView.tableHeaderView = [self liveOverrideHeaderView]; + } else { + self.tableView.tableHeaderView = [self restartNoticeHeaderView]; + self.tableView.tableFooterView = [self versionFooterView]; + } + [self updateTableHeaderAndFooterLayout]; + } +} + +- (void)markPendingChangesForItem:(const IOSConfigItem*)item { + if (live_override_) { + return; + } + if (item && !item->key.empty()) { + dirty_keys_.insert(item->key); + } + hasPendingChanges_ = YES; + saveButton_.enabled = YES; +} + +- (void)configItemDidChange:(const IOSConfigItem*)__unused item { +} + +- (BOOL)isRootSettingsController { + UINavigationController* navigation_controller = self.navigationController; + if (!navigation_controller) { + return YES; + } + return navigation_controller.viewControllers.count == 0 || + navigation_controller.viewControllers.firstObject == self; +} + +- (void)updateNavigationItems { + if ([self isRootSettingsController]) { + if (shows_root_dismiss_button_) { + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancelTapped:)] + autorelease]; + } else { + self.navigationItem.leftBarButtonItem = nil; + } + } else { + self.navigationItem.leftBarButtonItem = nil; + } + self.navigationItem.rightBarButtonItem = live_override_ ? nil : saveButton_; +} + +- (IOSConfigItem*)itemAtIndexPath:(NSIndexPath*)indexPath { + if (indexPath.section < 0 || indexPath.section >= static_cast(sections_.size())) { + return nullptr; + } + IOSConfigSection& section = sections_[indexPath.section]; + if (indexPath.row < 0 || indexPath.row >= static_cast(section.items.size())) { + return nullptr; + } + return §ion.items[indexPath.row]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView { + return static_cast(sections_.size()); +} + +- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section { + if (section < 0 || section >= static_cast(sections_.size())) { + return 0; + } + return static_cast(sections_[section].items.size()); +} + +- (NSString*)tableView:(UITableView*)tableView titleForHeaderInSection:(NSInteger)section { + if (section < 0 || section >= static_cast(sections_.size())) { + return nil; + } + return ToNSString(sections_[section].title); +} + +- (NSString*)tableView:(UITableView*)tableView titleForFooterInSection:(NSInteger)section { + if (section < 0 || section >= static_cast(sections_.size())) { + return nil; + } + return sections_[section].footer.empty() ? nil : ToNSString(sections_[section].footer); +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaConfigCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier]; + } + + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item) { + cell.contentConfiguration = nil; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + UIListContentConfiguration* content = [UIListContentConfiguration subtitleCellConfiguration]; + content.text = ToNSString(item->title); + content.textProperties.color = [XeniaTheme textPrimary]; + content.textProperties.numberOfLines = 0; + content.textProperties.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + content.secondaryTextProperties.color = [XeniaTheme textSecondary]; + content.secondaryTextProperties.numberOfLines = 0; + content.secondaryTextProperties.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + content.prefersSideBySideTextAndSecondaryText = NO; + content.directionalLayoutMargins = NSDirectionalEdgeInsetsMake(12.0, 0.0, 12.0, 0.0); + + if (item->control_type == IOSConfigControlType::kToggle) { + content.secondaryText = ToNSString(item->subtitle); + UISwitch* toggle = [[[UISwitch alloc] init] autorelease]; + toggle.on = item->bool_value; + toggle.accessibilityLabel = content.text; + toggle.accessibilityValue = toggle.on ? @"On" : @"Off"; + toggle.accessibilityHint = content.secondaryText; + [toggle addTarget:self + action:@selector(toggleChanged:) + forControlEvents:UIControlEventValueChanged]; + cell.contentConfiguration = content; + cell.accessoryView = toggle; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.isAccessibilityElement = NO; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } else if (item->control_type == IOSConfigControlType::kInteger) { + content.secondaryText = ToNSString(item->subtitle); + UITextField* field = [[[UITextField alloc] init] autorelease]; + field.text = [NSString stringWithFormat:@"%lld", item->integer_value]; + field.textAlignment = NSTextAlignmentRight; + field.keyboardType = UIKeyboardTypeNumbersAndPunctuation; + field.font = [UIFont monospacedDigitSystemFontOfSize:16.0 weight:UIFontWeightRegular]; + field.textColor = [XeniaTheme textSecondary]; + field.frame = CGRectMake(0, 0, 100, 32); + [field addTarget:self + action:@selector(integerFieldChanged:) + forControlEvents:UIControlEventEditingDidEnd]; + cell.contentConfiguration = content; + cell.accessoryView = field; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.isAccessibilityElement = NO; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } else if (item->control_type == IOSConfigControlType::kDouble) { + content.secondaryText = ToNSString(item->subtitle); + UITextField* field = [[[UITextField alloc] init] autorelease]; + field.text = [NSString stringWithFormat:@"%.6g", item->double_value]; + field.textAlignment = NSTextAlignmentRight; + field.keyboardType = UIKeyboardTypeDecimalPad; + field.font = [UIFont monospacedDigitSystemFontOfSize:16.0 weight:UIFontWeightRegular]; + field.textColor = [XeniaTheme textSecondary]; + field.frame = CGRectMake(0, 0, 100, 32); + [field addTarget:self + action:@selector(doubleFieldChanged:) + forControlEvents:UIControlEventEditingDidEnd]; + cell.contentConfiguration = content; + cell.accessoryView = field; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.isAccessibilityElement = NO; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } else if (item->control_type == IOSConfigControlType::kString || + item->control_type == IOSConfigControlType::kPath) { + content.secondaryText = ToNSString(item->subtitle); + UITextField* field = [[[UITextField alloc] init] autorelease]; + field.text = ToNSString(item->string_value); + field.textAlignment = NSTextAlignmentRight; + field.autocapitalizationType = UITextAutocapitalizationTypeNone; + field.autocorrectionType = UITextAutocorrectionTypeNo; + field.font = [UIFont systemFontOfSize:16.0]; + field.textColor = [XeniaTheme textSecondary]; + field.frame = CGRectMake(0, 0, 120, 32); + [field addTarget:self + action:@selector(stringFieldChanged:) + forControlEvents:UIControlEventEditingDidEnd]; + cell.contentConfiguration = content; + cell.accessoryView = field; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.isAccessibilityElement = NO; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } else if (item->control_type == IOSConfigControlType::kAction) { + content.textProperties.color = self.view.tintColor; + content.secondaryText = ToNSString(item->subtitle); + cell.contentConfiguration = content; + cell.accessoryView = nil; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = content.text; + cell.accessibilityValue = content.secondaryText; + cell.accessibilityHint = @"Opens another settings page."; + cell.accessibilityTraits = UIAccessibilityTraitButton; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } else { + // kChoiceInt32, kChoiceUInt32, kChoiceUInt64, kChoiceString, kEnum + std::string value_title = ChoiceTitleForItem(*item); + std::string subtitle = item->subtitle; + if (!value_title.empty()) { + content.secondaryText = ToNSString(value_title + " · " + subtitle); + } else { + content.secondaryText = ToNSString(subtitle); + } + cell.contentConfiguration = content; + cell.accessoryView = nil; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.isAccessibilityElement = YES; + cell.accessibilityLabel = ToNSString(item->title); + cell.accessibilityValue = value_title.empty() ? nil : ToNSString(value_title); + cell.accessibilityHint = @"Opens choices for this setting."; + cell.accessibilityTraits = UIAccessibilityTraitButton; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + + return cell; +} + +- (void)toggleChanged:(UISwitch*)sender { + CGPoint point = [sender convertPoint:CGPointZero toView:self.tableView]; + NSIndexPath* indexPath = [self.tableView indexPathForRowAtPoint:point]; + if (!indexPath) { + return; + } + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item || item->control_type != IOSConfigControlType::kToggle) { + return; + } + item->bool_value = sender.isOn; + sender.accessibilityValue = sender.isOn ? @"On" : @"Off"; + [self markPendingChangesForItem:item]; + if (live_override_) { + [self applyLiveOverrideForItem:item]; + } + [self configItemDidChange:item]; +} + +- (void)integerFieldChanged:(UITextField*)sender { + CGPoint point = [sender convertPoint:CGPointZero toView:self.tableView]; + NSIndexPath* indexPath = [self.tableView indexPathForRowAtPoint:point]; + if (!indexPath) return; + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item || item->control_type != IOSConfigControlType::kInteger) return; + + const char* utf8 = [sender.text UTF8String]; + if (!utf8) return; + char* end = nullptr; + long long parsed = std::strtoll(utf8, &end, 10); + if (end != utf8 && *end == '\0') { + item->integer_value = static_cast(parsed); + [self markPendingChangesForItem:item]; + if (live_override_) { + [self applyLiveOverrideForItem:item]; + } + [self configItemDidChange:item]; + } else { + sender.text = [NSString stringWithFormat:@"%lld", item->integer_value]; + } +} + +- (void)doubleFieldChanged:(UITextField*)sender { + CGPoint point = [sender convertPoint:CGPointZero toView:self.tableView]; + NSIndexPath* indexPath = [self.tableView indexPathForRowAtPoint:point]; + if (!indexPath) return; + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item || item->control_type != IOSConfigControlType::kDouble) return; + + const char* utf8 = [sender.text UTF8String]; + if (!utf8) return; + char* end = nullptr; + double parsed = std::strtod(utf8, &end); + if (end != utf8 && *end == '\0') { + item->double_value = parsed; + [self markPendingChangesForItem:item]; + if (live_override_) { + [self applyLiveOverrideForItem:item]; + } + [self configItemDidChange:item]; + } else { + sender.text = [NSString stringWithFormat:@"%.6g", item->double_value]; + } +} + +- (void)stringFieldChanged:(UITextField*)sender { + CGPoint point = [sender convertPoint:CGPointZero toView:self.tableView]; + NSIndexPath* indexPath = [self.tableView indexPathForRowAtPoint:point]; + if (!indexPath) return; + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item) return; + if (item->control_type != IOSConfigControlType::kString && + item->control_type != IOSConfigControlType::kPath) { + return; + } + + item->string_value = sender.text ? [sender.text UTF8String] : ""; + [self markPendingChangesForItem:item]; + if (live_override_) { + [self applyLiveOverrideForItem:item]; + } + [self configItemDidChange:item]; +} + +- (void)applyLiveOverrideForItem:(IOSConfigItem*)item { + if (!item || item->key.empty()) return; + switch (item->control_type) { + case IOSConfigControlType::kToggle: + OverrideCvarByName(item->key, item->bool_value); + break; + case IOSConfigControlType::kInteger: + OverrideIntegerCvarByName(item->key, item->integer_value); + break; + case IOSConfigControlType::kDouble: + OverrideFloatingCvarByName(item->key, item->double_value); + break; + case IOSConfigControlType::kString: + case IOSConfigControlType::kPath: + OverrideStringLikeCvarByName(item->key, item->string_value); + break; + case IOSConfigControlType::kEnum: + case IOSConfigControlType::kChoiceString: + OverrideStringLikeCvarByName(item->key, item->string_value); + break; + case IOSConfigControlType::kChoiceInt32: + OverrideIntegerCvarByName(item->key, item->choice_value); + break; + case IOSConfigControlType::kChoiceUInt32: + OverrideIntegerCvarByName(item->key, item->choice_value); + break; + case IOSConfigControlType::kChoiceUInt64: + OverrideIntegerCvarByName(item->key, item->choice_value); + break; + default: + break; + } +} + +- (void)pushCatalogKind:(IOSConfigCatalogKind)catalogKind { + XeniaConfigViewController* config_vc = nil; + if (catalogKind == IOSConfigCatalogKind::kAllCvars || + catalogKind == IOSConfigCatalogKind::kDebugSettings || + catalogKind == IOSConfigCatalogKind::kAdvanced) { + config_vc = [[XeniaIOSDebugSettingsViewController alloc] + initWithCatalogKind:catalogKind + liveOverride:NO + gameTitleID:game_title_id_ + gameTitle:game_title_]; + } else { + config_vc = [[XeniaConfigViewController alloc] initWithCatalogKind:catalogKind + style:UITableViewStyleInsetGrouped + gameTitleID:game_title_id_ + gameTitle:game_title_]; + } + config_vc.dismissalHandler = self.dismissalHandler; + config_vc.showsRootDismissButton = self.showsRootDismissButton; + [self.navigationController pushViewController:config_vc animated:YES]; + [config_vc release]; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + IOSConfigItem* item = [self itemAtIndexPath:indexPath]; + if (!item) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + + if (item->control_type == IOSConfigControlType::kToggle) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + + // Text-input types: focus the accessory text field. + if (item->control_type == IOSConfigControlType::kInteger || + item->control_type == IOSConfigControlType::kDouble || + item->control_type == IOSConfigControlType::kString || + item->control_type == IOSConfigControlType::kPath) { + UITableViewCell* cell = [tableView cellForRowAtIndexPath:indexPath]; + if ([cell.accessoryView isKindOfClass:[UITextField class]]) { + [(UITextField*)cell.accessoryView becomeFirstResponder]; + } + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + + if (item->control_type == IOSConfigControlType::kAction) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + switch (item->action) { + case IOSConfigAction::kOpenAdvancedSettings: + [self pushCatalogKind:IOSConfigCatalogKind::kAdvanced]; + break; + case IOSConfigAction::kOpenDiagnosticsSettings: + [self pushCatalogKind:IOSConfigCatalogKind::kDiagnostics]; + break; + case IOSConfigAction::kOpenAllConfigSettings: + [self pushCatalogKind:IOSConfigCatalogKind::kAllCvars]; + break; + case IOSConfigAction::kViewRecentLog: { + XeniaLogViewController* log_vc = [[XeniaLogViewController alloc] init]; + [self.navigationController pushViewController:log_vc animated:YES]; + [log_vc release]; + } break; + case IOSConfigAction::kNone: + default: + break; + } + return; + } + + NSIndexPath* selected_index_path = indexPath; + XeniaChoiceListViewController* choice_vc = [[XeniaChoiceListViewController alloc] + initWithTitle:ToNSString(item->title) + subtitle:ToNSString(item->subtitle) + choices:item->choices + selectedValue:item->choice_value + onSelection:^(int64_t selected_value) { + IOSConfigItem* selected_item = [self itemAtIndexPath:selected_index_path]; + if (!selected_item) { + return; + } + selected_item->choice_value = selected_value; + if ((selected_item->control_type == IOSConfigControlType::kChoiceString || + selected_item->control_type == IOSConfigControlType::kEnum) && + selected_value >= 0 && + selected_value < static_cast(selected_item->choice_string_values.size())) { + selected_item->string_value = + selected_item->choice_string_values[static_cast(selected_value)]; + } + [self markPendingChangesForItem:selected_item]; + if (live_override_) { + [self applyLiveOverrideForItem:selected_item]; + } + [self configItemDidChange:selected_item]; + [self.tableView reloadRowsAtIndexPaths:@[ selected_index_path ] + withRowAnimation:UITableViewRowAnimationNone]; + }]; + [self.navigationController pushViewController:choice_vc animated:YES]; + [tableView deselectRowAtIndexPath:indexPath animated:YES]; +} + +- (void)dismissSettingsControllerAnimated:(BOOL)animated { + void (^dismissal_handler)(void) = [dismissal_handler_ copy]; + [self dismissViewControllerAnimated:animated + completion:^{ + if (dismissal_handler) { + dismissal_handler(); + } + [dismissal_handler release]; + }]; +} + +- (void)cancelTapped:(id)sender { + if (!hasPendingChanges_) { + [self dismissSettingsControllerAnimated:YES]; + return; + } + + UIAlertController* confirm = + [UIAlertController alertControllerWithTitle:@"Discard Changes?" + message:@"You have unsaved setting changes." + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Keep Editing" + style:UIAlertActionStyleCancel + handler:nil]]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Discard" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction* action) { + [self dismissSettingsControllerAnimated:YES]; + }]]; + [self presentViewController:confirm animated:YES completion:nil]; +} + +- (void)backTapped:(id)sender { + (void)sender; + if (!hasPendingChanges_) { + [self.navigationController popViewControllerAnimated:YES]; + return; + } + + UIAlertController* confirm = + [UIAlertController alertControllerWithTitle:@"Discard Changes?" + message:@"You have unsaved setting changes." + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Keep Editing" + style:UIAlertActionStyleCancel + handler:nil]]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Discard" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction* action) { + [self.navigationController + popViewControllerAnimated:YES]; + }]]; + [self presentViewController:confirm animated:YES completion:nil]; +} + +- (void)saveTapped:(id)sender { + if (live_override_) { + return; + } + const std::vector sections_for_save = [self sectionsForSaving]; + BOOL saved = game_title_id_ + ? (ApplyIOSConfigSectionsToGameConfig(sections_for_save, game_title_id_, + dirty_keys_) + ? YES + : NO) + : (ApplyIOSConfigSections(sections_for_save) ? YES : NO); + hasPendingChanges_ = NO; + dirty_keys_.clear(); + saveButton_.enabled = NO; + + NSString* title = nil; + NSString* message = nil; + if (game_title_id_) { + title = saved ? @"Game Settings Saved" : @"Save Completed With Warnings"; + message = saved ? @"Saved title-specific overrides. Relaunch the game before testing them." + : @"Some title-specific overrides could not be saved. Check xenia.log."; + } else { + title = saved ? @"Settings Saved" : @"Save Completed With Warnings"; + message = saved ? @"Saved to XeniOS settings.\n\nFor reliable results, change " + @"settings before launching a game. If you saved while a game was " + @"already running, fully relaunch XeniOS before testing." + : @"Some settings could not be applied. Check xenia.log.\n\nAny " + @"settings that were saved should be tested after a full XeniOS " + @"relaunch, or by changing them before launching a game."; + } + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Done" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + [self dismissSettingsControllerAnimated:YES]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.h b/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.h new file mode 100644 index 000000000..139a22849 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.h @@ -0,0 +1,27 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_DEBUG_SETTINGS_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_DEBUG_SETTINGS_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/settings/ios_config_view_controller.h" + +// Searchable config controller for curated Advanced Debug and All Config pages. +@interface XeniaIOSDebugSettingsViewController : XeniaConfigViewController +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + liveOverride:(BOOL)liveOverride; +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + liveOverride:(BOOL)liveOverride + gameTitleID:(uint32_t)gameTitleID + gameTitle:(NSString*)gameTitle; +@end + +#endif // XENIA_UI_IOS_DEBUG_SETTINGS_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.mm b/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.mm new file mode 100644 index 000000000..c59ae5c50 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_debug_settings_view_controller.mm @@ -0,0 +1,128 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_debug_settings_view_controller.h" + +#include +#include +#include +#include + +#include "xenia/ui/ios/settings/ios_config_catalog.h" +#include "xenia/ui/ios/settings/ios_config_models.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace { + +std::string Lowercase(const std::string& s) { + std::string out = s; + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return std::tolower(c); }); + return out; +} + +} // namespace + +@implementation XeniaIOSDebugSettingsViewController { + std::vector full_sections_; + UISearchController* search_controller_; +} + +- (instancetype)init { + return [self initWithCatalogKind:IOSConfigCatalogKind::kDebugSettings liveOverride:YES]; +} + +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + liveOverride:(BOOL)liveOverride { + return [self initWithCatalogKind:catalogKind + liveOverride:liveOverride + gameTitleID:0 + gameTitle:nil]; +} + +- (instancetype)initWithCatalogKind:(IOSConfigCatalogKind)catalogKind + liveOverride:(BOOL)liveOverride + gameTitleID:(uint32_t)gameTitleID + gameTitle:(NSString*)gameTitle { + self = [super initWithCatalogKind:catalogKind + style:UITableViewStyleInsetGrouped + gameTitleID:gameTitleID + gameTitle:gameTitle]; + if (self) { + self.liveOverride = liveOverride; + self.showsRootDismissButton = NO; + } + return self; +} + +- (std::vector)buildSections { + full_sections_ = [super buildSections]; + return full_sections_; +} + +- (std::vector)sectionsForSaving { + return full_sections_; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + search_controller_ = [[UISearchController alloc] initWithSearchResultsController:nil]; + search_controller_.searchResultsUpdater = self; + search_controller_.obscuresBackgroundDuringPresentation = NO; + search_controller_.hidesNavigationBarDuringPresentation = YES; + self.navigationItem.searchController = search_controller_; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; +} + +- (void)dealloc { + [search_controller_ release]; + [super dealloc]; +} + +- (void)configItemDidChange:(const IOSConfigItem*)item { + if (!item) { + return; + } + for (auto& section : full_sections_) { + for (auto& full_item : section.items) { + if (full_item.key == item->key) { + full_item = *item; + return; + } + } + } +} + +#pragma mark - UISearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController*)searchController { + NSString* query = searchController.searchBar.text; + if (query.length == 0) { + [self replaceSections:full_sections_]; + return; + } + + std::string lower = Lowercase([query UTF8String]); + IOSConfigSection results; + results.title = "Results"; + for (const auto& section : full_sections_) { + for (const auto& item : section.items) { + if (Lowercase(item.key).find(lower) != std::string::npos || + Lowercase(item.title).find(lower) != std::string::npos || + Lowercase(item.subtitle).find(lower) != std::string::npos) { + results.items.push_back(item); + } + } + } + [self replaceSections:{results}]; +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_log_view_controller.h b/src/xenia/ui/ios/settings/ios_log_view_controller.h new file mode 100644 index 000000000..ff8820707 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_log_view_controller.h @@ -0,0 +1,25 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_LOG_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_LOG_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/shared/apple_ui_navigation.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +// Live log viewer presented as a sheet from the launcher and from the +// in-game overlay. Tails xenia.log every 0.5s, supports refresh / share / +// gamepad navigation (LB/RB to scroll, X share, Y refresh). +@interface XeniaLogViewController : XESheetViewController +- (BOOL)handleControllerActions:(const xe::ui::apple::ControllerActionSet&)actions; +@end + +#endif // XENIA_UI_IOS_LOG_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_log_view_controller.mm b/src/xenia/ui/ios/settings/ios_log_view_controller.mm new file mode 100644 index 000000000..7378ba2e7 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_log_view_controller.mm @@ -0,0 +1,367 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_log_view_controller.h" + +#include +#include +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/ui/ios/shared/ios_system_utils.h" +#include "xenia/ui/ios/shared/ios_theme.h" +#include "xenia/ui/ios/shared/ios_theme_controls.h" + +DECLARE_path(log_file); + +namespace { + +std::filesystem::path GetLogFilePath() { + if (!cvars::log_file.empty()) { + return cvars::log_file; + } + return xe_get_ios_documents_path() / "xenia.log"; +} + +std::string ReadFileTail(const std::filesystem::path& path, size_t max_bytes) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return std::string(); + } + + file.seekg(0, std::ios::end); + const std::streamoff end = file.tellg(); + if (end <= 0) { + return std::string(); + } + + const std::streamoff max_read = static_cast(max_bytes); + const std::streamoff start = end > max_read ? (end - max_read) : 0; + file.seekg(start, std::ios::beg); + + std::string content(static_cast(end - start), '\0'); + file.read(content.data(), static_cast(content.size())); + content.resize(static_cast(file.gcount())); + + if (start > 0) { + size_t first_newline = content.find('\n'); + if (first_newline != std::string::npos) { + content.erase(0, first_newline + 1); + } + } + return content; +} + +NSString* DecodeLogBytesToNSString(const std::string& content) { + if (content.empty()) { + return @""; + } + + NSData* log_data = [NSData dataWithBytes:content.data() length:content.size()]; + NSString* decoded = [[NSString alloc] initWithData:log_data encoding:NSUTF8StringEncoding]; + if (!decoded) { + decoded = [[NSString alloc] + initWithData:log_data + encoding:CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingWindowsLatin1)]; + } + if (!decoded) { + decoded = [[NSString alloc] initWithData:log_data encoding:NSISOLatin1StringEncoding]; + } + if (decoded) { + return decoded; + } + + // Last-resort byte-preserving fallback so the UI never shows a hard decode + // failure marker. + NSMutableString* fallback = [NSMutableString stringWithCapacity:content.size()]; + for (unsigned char byte : content) { + if (byte == '\n' || byte == '\r' || byte == '\t' || (byte >= 0x20 && byte <= 0x7E)) { + [fallback appendFormat:@"%c", byte]; + } else { + [fallback appendFormat:@"\\x%02X", byte]; + } + } + return fallback; +} + +} // namespace + +@implementation XeniaLogViewController { + UITextView* textView_; + UILabel* footerLabel_; + NSTimer* autoRefreshTimer_; + UIBarButtonItem* pauseButton_; + BOOL paused_; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Live Log"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + pauseButton_ = + [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"pause.fill"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(togglePauseTapped:)]; + [self updatePauseButton]; + UIBarButtonItem* more_button = [[[UIBarButtonItem alloc] + initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] + menu:[self buildActionsMenu]] autorelease]; + self.navigationItem.rightBarButtonItems = @[ more_button, pauseButton_ ]; + + textView_ = [[UITextView alloc] init]; + textView_.translatesAutoresizingMaskIntoConstraints = NO; + textView_.editable = NO; + textView_.selectable = YES; + textView_.alwaysBounceVertical = YES; + textView_.backgroundColor = [XeniaTheme bgSurface2]; + xe_apply_text_view_font(textView_, UIFontTextStyleFootnote, 12.0, + UIFontWeightRegular, YES); + textView_.textContainerInset = UIEdgeInsetsMake(12, 12, 12, 12); + [self.view addSubview:textView_]; + + footerLabel_ = [[UILabel alloc] init]; + footerLabel_.translatesAutoresizingMaskIntoConstraints = NO; + footerLabel_.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(footerLabel_, UIFontTextStyleCaption1, 12.0, + UIFontWeightRegular); + footerLabel_.numberOfLines = 2; + [self.view addSubview:footerLabel_]; + + UILayoutGuide* guide = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [textView_.topAnchor constraintEqualToAnchor:guide.topAnchor constant:8], + [textView_.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor constant:10], + [textView_.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor constant:-10], + [textView_.bottomAnchor constraintEqualToAnchor:footerLabel_.topAnchor constant:-8], + [footerLabel_.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor constant:12], + [footerLabel_.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor constant:-12], + [footerLabel_.bottomAnchor constraintEqualToAnchor:guide.bottomAnchor constant:-8], + ]]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self updateCloseButton]; + [self reloadLog]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self startAutoRefresh]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self stopAutoRefresh]; +} + +- (void)dealloc { + [self stopAutoRefresh]; + [pauseButton_ release]; + [super dealloc]; +} + +- (void)reloadLogTapped:(id)sender { + [self reloadLog]; +} + +- (UIMenu*)buildActionsMenu { + __unsafe_unretained XeniaLogViewController* weak_self = self; + UIAction* copy_action = + [UIAction actionWithTitle:@"Copy" + image:[UIImage systemImageNamed:@"doc.on.doc"] + identifier:nil + handler:^(__unused UIAction* action) { [weak_self copyLog]; }]; + UIAction* share_action = + [UIAction actionWithTitle:@"Share" + image:[UIImage systemImageNamed:@"square.and.arrow.up"] + identifier:nil + handler:^(__unused UIAction* action) { [weak_self shareLogTapped:nil]; }]; + UIAction* refresh_action = + [UIAction actionWithTitle:@"Refresh" + image:[UIImage systemImageNamed:@"arrow.clockwise"] + identifier:nil + handler:^(__unused UIAction* action) { [weak_self reloadLog]; }]; + return [UIMenu menuWithTitle:@"" children:@[ copy_action, share_action, refresh_action ]]; +} + +- (void)togglePauseTapped:(id)sender { + paused_ = !paused_; + if (paused_) { + [self stopAutoRefresh]; + footerLabel_.text = @"Paused. Resume to continue the live tail."; + } else { + [self startAutoRefresh]; + [self reloadLog]; + } + [self updatePauseButton]; +} + +- (void)updatePauseButton { + pauseButton_.image = [UIImage systemImageNamed:(paused_ ? @"play.fill" : @"pause.fill")]; + pauseButton_.accessibilityLabel = paused_ ? @"Resume live log" : @"Pause live log"; +} + +- (void)copyLog { + [UIPasteboard generalPasteboard].string = textView_.text.length ? textView_.text : @""; +} + +- (void)closeTapped:(id)sender { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (BOOL)shouldShowCloseButton { + UINavigationController* nav = self.navigationController; + if (!nav || nav.presentingViewController == nil) { + return NO; + } + return nav.viewControllers.count > 0 && nav.viewControllers.firstObject == self; +} + +- (void)updateCloseButton { + if ([self shouldShowCloseButton]) { + self.navigationItem.leftBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(closeTapped:)]; + } else { + self.navigationItem.leftBarButtonItem = nil; + } +} + +- (void)startAutoRefresh { + if (autoRefreshTimer_ || paused_) { + return; + } + autoRefreshTimer_ = [NSTimer scheduledTimerWithTimeInterval:0.5 + target:self + selector:@selector(autoRefreshTick:) + userInfo:nil + repeats:YES]; + autoRefreshTimer_.tolerance = 0.15; +} + +- (void)stopAutoRefresh { + if (!autoRefreshTimer_) { + return; + } + [autoRefreshTimer_ invalidate]; + autoRefreshTimer_ = nil; +} + +- (void)autoRefreshTick:(NSTimer* __unused)timer { + [self reloadLog]; +} + +- (BOOL)handleControllerActions:(const xe::ui::apple::ControllerActionSet&)actions { + if (actions.context) { + [self shareLogTapped:nil]; + return YES; + } + if (actions.quick_action) { + [self reloadLogTapped:nil]; + return YES; + } + if (!(actions.navigate_up || actions.navigate_down || actions.page_prev || actions.page_next)) { + return NO; + } + + CGFloat step = MAX(40.0, CGRectGetHeight(textView_.bounds) * 0.45); + CGFloat offset = textView_.contentOffset.y; + if (actions.navigate_up) { + offset -= step; + } + if (actions.navigate_down) { + offset += step; + } + if (actions.page_prev) { + offset -= CGRectGetHeight(textView_.bounds); + } + if (actions.page_next) { + offset += CGRectGetHeight(textView_.bounds); + } + CGFloat max_offset = MAX(0.0, textView_.contentSize.height - CGRectGetHeight(textView_.bounds)); + offset = MIN(MAX(0.0, offset), max_offset); + [textView_ setContentOffset:CGPointMake(0.0, offset) animated:YES]; + return YES; +} + +- (BOOL)isNearBottom { + CGFloat content_height = textView_.contentSize.height; + CGFloat visible_height = CGRectGetHeight(textView_.bounds); + if (content_height <= visible_height + 1.0) { + return YES; + } + CGFloat bottom = textView_.contentOffset.y + visible_height; + return bottom >= content_height - 36.0; +} + +- (void)shareLogTapped:(id)sender { + std::filesystem::path log_path = GetLogFilePath(); + NSString* log_path_ns = ToNSString(log_path.string()); + if (![[NSFileManager defaultManager] fileExistsAtPath:log_path_ns]) { + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:@"Log Not Found" + message:@"No xenia.log file exists yet." + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + return; + } + + NSURL* log_url = [NSURL fileURLWithPath:log_path_ns]; + UIActivityViewController* share_controller = + [[UIActivityViewController alloc] initWithActivityItems:@[ log_url ] + applicationActivities:nil]; + UIPopoverPresentationController* popover = share_controller.popoverPresentationController; + if (popover) { + popover.barButtonItem = self.navigationItem.rightBarButtonItems.lastObject; + } + [self presentViewController:share_controller animated:YES completion:nil]; +} + +- (void)reloadLog { + static constexpr size_t kMaxLogBytes = 256 * 1024; + BOOL should_follow_tail = [self isNearBottom]; + std::filesystem::path log_path = GetLogFilePath(); + std::string content = ReadFileTail(log_path, kMaxLogBytes); + NSString* log_path_ns = ToNSString(log_path.string()); + + NSString* display_text = nil; + if (content.empty()) { + display_text = + [NSString stringWithFormat:@"No recent log data found.\n\nExpected file:\n%@\n\n" + @"Launch a game and keep this open. This view updates " + @"automatically.", + log_path_ns]; + } else { + display_text = DecodeLogBytesToNSString(content); + } + + if (!display_text) { + display_text = @""; + } + if (![textView_.text isEqualToString:display_text]) { + textView_.text = display_text; + } + if (should_follow_tail) { + [textView_ scrollRangeToVisible:NSMakeRange(textView_.text.length, 0)]; + } + + footerLabel_.text = [NSString stringWithFormat:@"Live tail (0.5s). Showing last %zu KB from %@", + kMaxLogBytes / 1024, log_path_ns]; +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_profile_view_controller.h b/src/xenia/ui/ios/settings/ios_profile_view_controller.h new file mode 100644 index 000000000..0349da391 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_profile_view_controller.h @@ -0,0 +1,30 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_PROFILE_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_PROFILE_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +typedef void (^IOSProfileStatusHandler)(NSString* status_message); + +// Local profile management sheet: create new gamertags and sign existing +// profiles into emulation slots. Status messages are forwarded back to the +// presenter via the IOSProfileStatusHandler block. +@interface XeniaProfileViewController : XESheetTableViewController +@property(nonatomic, assign) BOOL showsDismissButton; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(IOSProfileStatusHandler)on_status; +@end + +#endif // XENIA_UI_IOS_PROFILE_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_profile_view_controller.mm b/src/xenia/ui/ios/settings/ios_profile_view_controller.mm new file mode 100644 index 000000000..c2ea45c3e --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_profile_view_controller.mm @@ -0,0 +1,217 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_profile_view_controller.h" + +#include + +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaProfileViewController { + xe::ui::IOSWindowedAppContext* app_context_; + IOSProfileStatusHandler on_status_; + BOOL shows_dismiss_button_; + std::vector profiles_; +} + +@synthesize showsDismissButton = shows_dismiss_button_; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(IOSProfileStatusHandler)on_status { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + app_context_ = app_context; + on_status_ = [on_status copy]; + shows_dismiss_button_ = YES; + self.title = @"Profiles"; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 16, 0, 16); + if (shows_dismiss_button_) { + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(doneTapped:)] + autorelease]; + } +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self reloadProfiles]; +} + +- (void)doneTapped:(id)sender { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)reloadProfiles { + profiles_.clear(); + if (app_context_) { + profiles_ = app_context_->ListProfiles(); + } + [self.tableView reloadData]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 2; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView numberOfRowsInSection:(NSInteger)section { + if (section == 0) { + return 1; + } + return static_cast(profiles_.size()); +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForHeaderInSection:(NSInteger)section { + if (section == 0) { + return @"Actions"; + } + return @"Local Profiles"; +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForFooterInSection:(NSInteger)section { + if (section == 0) { + return @"Create and sign in profiles used by Xbox Live emulation."; + } + if (profiles_.empty()) { + return @"No profiles created yet."; + } + return nil; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaProfileCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier]; + } + + cell.textLabel.numberOfLines = 1; + cell.detailTextLabel.numberOfLines = 1; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + + if (indexPath.section == 0) { + cell.textLabel.text = @"Create Profile"; + cell.textLabel.textColor = [XeniaTheme accent]; + cell.detailTextLabel.text = @"Create a new profile and sign in."; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + + if (indexPath.row < 0 || indexPath.row >= static_cast(profiles_.size())) { + cell.textLabel.text = @""; + cell.detailTextLabel.text = @""; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + const auto& profile = profiles_[indexPath.row]; + NSString* gamertag = ToNSString(profile.gamertag); + cell.textLabel.text = gamertag; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + if (profile.signed_in) { + cell.detailTextLabel.text = + [NSString stringWithFormat:@"Signed in on slot %u", profile.signed_in_slot]; + cell.accessoryType = UITableViewCellAccessoryCheckmark; + } else { + cell.detailTextLabel.text = @"Not signed in"; + } + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + return cell; +} + +- (void)presentCreateProfileAlert { + if (!app_context_) { + return; + } + + __block UIAlertController* create_alert = + [UIAlertController alertControllerWithTitle:@"Create Profile" + message:@"Enter a gamertag (1-15 characters)." + preferredStyle:UIAlertControllerStyleAlert]; + [create_alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.placeholder = @"Gamertag"; + text_field.autocapitalizationType = UITextAutocapitalizationTypeWords; + text_field.autocorrectionType = UITextAutocorrectionTypeNo; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + }]; + [create_alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + [create_alert + addAction:[UIAlertAction + actionWithTitle:@"Create" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + UITextField* text_field = create_alert.textFields.firstObject; + NSString* raw_text = text_field.text ?: @""; + NSString* trimmed = + [raw_text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0 || !app_context_) { + return; + } + NSString* gamertag = [[trimmed copy] autorelease]; + create_alert = nil; + uint64_t xuid = + app_context_->CreateProfile(std::string([gamertag UTF8String])); + if (!xuid || !app_context_->SignInProfile(xuid)) { + if (on_status_) { + on_status_(@"Failed to create profile."); + } + return; + } + if (on_status_) { + on_status_( + [NSString stringWithFormat:@"Signed in as %@.", gamertag]); + } + [self reloadProfiles]; + }]]; + [self presentViewController:create_alert animated:YES completion:nil]; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (!app_context_) { + return; + } + + if (indexPath.section == 0) { + [self presentCreateProfileAlert]; + return; + } + + if (indexPath.row < 0 || indexPath.row >= static_cast(profiles_.size())) { + return; + } + + const auto& profile = profiles_[indexPath.row]; + if (app_context_->SignInProfile(profile.xuid)) { + if (on_status_) { + on_status_([NSString stringWithFormat:@"Signed in as %@.", ToNSString(profile.gamertag)]); + } + [self reloadProfiles]; + } else if (on_status_) { + on_status_(@"Failed to sign in profile."); + } +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.h b/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.h new file mode 100644 index 000000000..5bf7da9c2 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.h @@ -0,0 +1,23 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_QUICK_SETTINGS_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_QUICK_SETTINGS_VIEW_CONTROLLER_H_ + +#import + +#include "xenia/ui/ios/settings/ios_config_view_controller.h" + +// Presented modally from the in-game overlay. Displays Graphics Compatibility +// settings with live override (no save button, immediate effect). Includes a +// footer row that pushes curated Advanced Debug settings. +@interface XeniaIOSQuickSettingsViewController : XeniaConfigViewController +@end + +#endif // XENIA_UI_IOS_QUICK_SETTINGS_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.mm b/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.mm new file mode 100644 index 000000000..52c88950c --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_quick_settings_view_controller.mm @@ -0,0 +1,59 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_quick_settings_view_controller.h" + +#include "xenia/ui/ios/settings/ios_config_models.h" +#import "xenia/ui/ios/settings/ios_debug_settings_view_controller.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaIOSQuickSettingsViewController + +- (instancetype)init { + self = [super initWithCatalogKind:IOSConfigCatalogKind::kGraphicsCompat + style:UITableViewStyleInsetGrouped]; + if (self) { + self.liveOverride = YES; + self.showsRootDismissButton = YES; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + UIView* footer = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 56)] autorelease]; + footer.backgroundColor = [UIColor clearColor]; + + UIButton* debugButton = [UIButton buttonWithType:UIButtonTypeSystem]; + debugButton.translatesAutoresizingMaskIntoConstraints = NO; + [debugButton setTitle:@"Show Advanced Debug" forState:UIControlStateNormal]; + debugButton.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + [debugButton addTarget:self + action:@selector(showDebugSettings) + forControlEvents:UIControlEventTouchUpInside]; + [footer addSubview:debugButton]; + + [NSLayoutConstraint activateConstraints:@[ + [debugButton.topAnchor constraintEqualToAnchor:footer.topAnchor constant:8.0], + [debugButton.centerXAnchor constraintEqualToAnchor:footer.centerXAnchor], + [debugButton.bottomAnchor constraintEqualToAnchor:footer.bottomAnchor constant:-8.0], + ]]; + + self.tableView.tableFooterView = footer; +} + +- (void)showDebugSettings { + XeniaIOSDebugSettingsViewController* debugVC = + [[XeniaIOSDebugSettingsViewController alloc] init]; + [self.navigationController pushViewController:debugVC animated:YES]; + [debugVC release]; +} + +@end diff --git a/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.h b/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.h new file mode 100644 index 000000000..a720f0f59 --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.h @@ -0,0 +1,79 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_SETTINGS_HUB_VIEW_CONTROLLER_H_ +#define XENIA_UI_IOS_SETTINGS_HUB_VIEW_CONTROLLER_H_ + +#include "xenia/ui/ios/app/windowed_app_context_ios.h" +#include "xenia/ui/ios/shared/ios_view_helpers.h" + +typedef void (^XeniaSettingsStatusHandler)(NSString* status_message); + +typedef NS_ENUM(NSInteger, XeniaSettingsInitialSection) { + XeniaSettingsInitialSectionMain, + XeniaSettingsInitialSectionProfile, +}; + +typedef NS_ENUM(NSInteger, XeniaSettingsHubAction) { + XeniaSettingsHubActionNone, + XeniaSettingsHubActionOpenTouchLayoutLibrary, + XeniaSettingsHubActionChooseGameTouchLayout, + XeniaSettingsHubActionEditTouchControls, + XeniaSettingsHubActionImportTouchLayout, + XeniaSettingsHubActionExportTouchLayout, + XeniaSettingsHubActionResetTouchLayout, + XeniaSettingsHubActionImportGame, + XeniaSettingsHubActionRefreshLibrary, +}; + +typedef void (^XeniaSettingsHubActionHandler)(XeniaSettingsHubAction action); + +@class XeniaSettingsHubViewController; + +@protocol XeniaSettingsHubViewControllerDelegate +- (void)settingsHubViewController:(XeniaSettingsHubViewController*)hub + didSelectController:(UIViewController*)controller; +@end + +// Settings category list used as the single-column iPhone settings root and as +// the primary column of the iPad settings workspace. It creates detail +// controllers through typed app-context callbacks, so the root app controller +// only needs to present the hub/workspace. +@interface XeniaSettingsHubViewController : XESheetTableViewController +@property(nonatomic, assign) id selectionDelegate; +@property(nonatomic, copy) void (^dismissalHandler)(void); +@property(nonatomic, copy) XeniaSettingsHubActionHandler actionHandler; +@property(nonatomic, assign) BOOL showsCloseButton; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status; +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status + initialSection:(XeniaSettingsInitialSection)initial_section; + +// Returns an autoreleased detail controller for the default workspace +// selection. +- (UIViewController*)makeInitialDetailController; +@end + +// iPad-oriented settings container. The hub lives in the primary column and +// selected details appear in the secondary column. Compact presentations can +// still use XeniaSettingsHubViewController directly for a native push stack. +@interface XeniaSettingsWorkspaceViewController : UISplitViewController +@property(nonatomic, copy) void (^dismissalHandler)(void); +@property(nonatomic, copy) XeniaSettingsHubActionHandler actionHandler; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status; +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status + initialSection:(XeniaSettingsInitialSection)initial_section; +@end + +#endif // XENIA_UI_IOS_SETTINGS_HUB_VIEW_CONTROLLER_H_ diff --git a/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.mm b/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.mm new file mode 100644 index 000000000..b2cc4fa1b --- /dev/null +++ b/src/xenia/ui/ios/settings/ios_settings_hub_view_controller.mm @@ -0,0 +1,1350 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/settings/ios_settings_hub_view_controller.h" + +#import "xenia/ui/ios/settings/ios_config_catalog.h" +#import "xenia/ui/ios/settings/ios_config_view_controller.h" +#import "xenia/ui/ios/settings/ios_debug_settings_view_controller.h" +#import "xenia/ui/ios/settings/ios_profile_view_controller.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +void XeniaOpenSettingsURL(NSString* url_string) { + if (!url_string.length) { + return; + } + NSURL* url = [NSURL URLWithString:url_string]; + if (!url) { + return; + } + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; +} + +NSString* XeniaBundleValue(NSString* key) { + id value = [[NSBundle mainBundle] objectForInfoDictionaryKey:key]; + return [value isKindOfClass:[NSString class]] ? value : nil; +} + +} // namespace + +typedef NS_ENUM(NSInteger, XeniaSettingsHubCategoryKind) { + XeniaSettingsHubCategoryKindProfile, + XeniaSettingsHubCategoryKindDisplay, + XeniaSettingsHubCategoryKindGraphics, + XeniaSettingsHubCategoryKindAudio, + XeniaSettingsHubCategoryKindControls, + XeniaSettingsHubCategoryKindTouchLayouts, + XeniaSettingsHubCategoryKindTouchBehavior, + XeniaSettingsHubCategoryKindPerformance, + XeniaSettingsHubCategoryKindCompatibility, + XeniaSettingsHubCategoryKindDiagnostics, + XeniaSettingsHubCategoryKindAdvancedDebug, + XeniaSettingsHubCategoryKindAllCvars, + XeniaSettingsHubCategoryKindLibrary, + XeniaSettingsHubCategoryKindAutomation, + XeniaSettingsHubCategoryKindAbout, +}; + +@interface XeniaSettingsHubItem : NSObject +@property(nonatomic, assign) XeniaSettingsHubCategoryKind kind; +@property(nonatomic, copy) NSString* title; +@property(nonatomic, copy) NSString* subtitle; +@property(nonatomic, copy) NSString* symbolName; +@property(nonatomic, retain) UIColor* tintColor; ++ (instancetype)itemWithKind:(XeniaSettingsHubCategoryKind)kind + title:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name + tintColor:(UIColor*)tint_color; +@end + +@implementation XeniaSettingsHubItem + ++ (instancetype)itemWithKind:(XeniaSettingsHubCategoryKind)kind + title:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name + tintColor:(UIColor*)tint_color { + XeniaSettingsHubItem* item = [[[XeniaSettingsHubItem alloc] init] autorelease]; + item.kind = kind; + item.title = title; + item.subtitle = subtitle; + item.symbolName = symbol_name; + item.tintColor = tint_color; + return item; +} + +- (void)dealloc { + [_title release]; + [_subtitle release]; + [_symbolName release]; + [_tintColor release]; + [super dealloc]; +} + +@end + +@interface XeniaSettingsHubSection : NSObject +@property(nonatomic, copy) NSString* title; +@property(nonatomic, retain) NSArray* items; ++ (instancetype)sectionWithTitle:(NSString*)title items:(NSArray*)items; +@end + +@implementation XeniaSettingsHubSection + ++ (instancetype)sectionWithTitle:(NSString*)title items:(NSArray*)items { + XeniaSettingsHubSection* section = + [[[XeniaSettingsHubSection alloc] init] autorelease]; + section.title = title; + section.items = items; + return section; +} + +- (void)dealloc { + [_title release]; + [_items release]; + [super dealloc]; +} + +@end + +static BOOL XeniaSettingsStringMatches(NSString* text, NSString* query) { + if (!query.length) { + return YES; + } + if (!text.length) { + return NO; + } + return [text rangeOfString:query + options:NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch] + .location != NSNotFound; +} + +static BOOL XeniaSettingsHubItemMatches(XeniaSettingsHubItem* item, + NSString* section_title, + NSString* query) { + return XeniaSettingsStringMatches(item.title, query) || + XeniaSettingsStringMatches(item.subtitle, query) || + XeniaSettingsStringMatches(section_title, query); +} + +static NSString* XeniaSettingsCatalogResultSubtitle(IOSConfigCatalogKind kind, + const IOSConfigItem& item) { + NSString* catalog_title = + [NSString stringWithUTF8String:IOSConfigCatalogTitle(kind).c_str()]; + NSString* item_subtitle = + item.subtitle.empty() ? nil : [NSString stringWithUTF8String:item.subtitle.c_str()]; + return item_subtitle.length + ? [NSString stringWithFormat:@"%@ · %@", catalog_title, item_subtitle] + : catalog_title; +} + +static BOOL XeniaSettingsConfigItemMatches(const IOSConfigItem& item, + const IOSConfigSection& section, + NSString* query) { + NSString* item_title = [NSString stringWithUTF8String:item.title.c_str()]; + NSString* item_subtitle = [NSString stringWithUTF8String:item.subtitle.c_str()]; + NSString* item_key = [NSString stringWithUTF8String:item.key.c_str()]; + NSString* section_title = [NSString stringWithUTF8String:section.title.c_str()]; + return XeniaSettingsStringMatches(item_title, query) || + XeniaSettingsStringMatches(item_subtitle, query) || + XeniaSettingsStringMatches(item_key, query) || + XeniaSettingsStringMatches(section_title, query); +} + +static XeniaSettingsHubCategoryKind XeniaSettingsKindForCatalog( + IOSConfigCatalogKind kind) { + switch (kind) { + case IOSConfigCatalogKind::kDisplay: + return XeniaSettingsHubCategoryKindDisplay; + case IOSConfigCatalogKind::kGraphics: + return XeniaSettingsHubCategoryKindGraphics; + case IOSConfigCatalogKind::kAudio: + return XeniaSettingsHubCategoryKindAudio; + case IOSConfigCatalogKind::kControls: + return XeniaSettingsHubCategoryKindTouchBehavior; + case IOSConfigCatalogKind::kPerformance: + return XeniaSettingsHubCategoryKindPerformance; + case IOSConfigCatalogKind::kCompatibility: + return XeniaSettingsHubCategoryKindCompatibility; + case IOSConfigCatalogKind::kDiagnostics: + return XeniaSettingsHubCategoryKindDiagnostics; + case IOSConfigCatalogKind::kDebugSettings: + return XeniaSettingsHubCategoryKindAdvancedDebug; + case IOSConfigCatalogKind::kSystem: + return XeniaSettingsHubCategoryKindAutomation; + default: + return XeniaSettingsHubCategoryKindAllCvars; + } +} + +static NSString* XeniaSettingsSymbolForCatalog(IOSConfigCatalogKind kind) { + switch (kind) { + case IOSConfigCatalogKind::kDisplay: + return @"display"; + case IOSConfigCatalogKind::kGraphics: + return @"gearshape.2.fill"; + case IOSConfigCatalogKind::kAudio: + return @"speaker.wave.2.fill"; + case IOSConfigCatalogKind::kControls: + return @"gamecontroller.fill"; + case IOSConfigCatalogKind::kPerformance: + return @"bolt.fill"; + case IOSConfigCatalogKind::kCompatibility: + return @"wrench.and.screwdriver.fill"; + case IOSConfigCatalogKind::kDiagnostics: + return @"waveform.path.ecg"; + case IOSConfigCatalogKind::kDebugSettings: + return @"slider.horizontal.3"; + case IOSConfigCatalogKind::kSystem: + return @"wand.and.stars"; + default: + return @"tablecells"; + } +} + +static UIColor* XeniaSettingsTintForCatalog(IOSConfigCatalogKind kind) { + switch (kind) { + case IOSConfigCatalogKind::kDisplay: + return [XeniaTheme sectionIconIndigo]; + case IOSConfigCatalogKind::kGraphics: + case IOSConfigCatalogKind::kDebugSettings: + return [XeniaTheme sectionIconPurple]; + case IOSConfigCatalogKind::kAudio: + case IOSConfigCatalogKind::kPerformance: + return [XeniaTheme sectionIconOrange]; + case IOSConfigCatalogKind::kControls: + return [XeniaTheme sectionIconBlue]; + case IOSConfigCatalogKind::kCompatibility: + case IOSConfigCatalogKind::kDiagnostics: + return [XeniaTheme sectionIconRed]; + case IOSConfigCatalogKind::kSystem: + return [XeniaTheme accent]; + default: + return [XeniaTheme sectionIconGray]; + } +} + +static void XeniaAppendCatalogSearchResults(NSMutableArray* results, + IOSConfigCatalogKind kind, + NSString* query) { + std::vector sections = BuildIOSConfigSectionsForKind(kind); + for (const IOSConfigSection& section : sections) { + for (const IOSConfigItem& item : section.items) { + if (!XeniaSettingsConfigItemMatches(item, section, query)) { + continue; + } + NSString* title = [NSString stringWithUTF8String:item.title.c_str()]; + [results addObject:[XeniaSettingsHubItem + itemWithKind:XeniaSettingsKindForCatalog(kind) + title:title + subtitle:XeniaSettingsCatalogResultSubtitle(kind, item) + symbolName:XeniaSettingsSymbolForCatalog(kind) + tintColor:XeniaSettingsTintForCatalog(kind)]]; + } + } +} + +@interface XeniaSettingsActionListItem : NSObject +@property(nonatomic, assign) XeniaSettingsHubAction action; +@property(nonatomic, assign) BOOL selectable; +@property(nonatomic, copy) NSString* title; +@property(nonatomic, copy) NSString* subtitle; +@property(nonatomic, copy) NSString* symbolName; +@property(nonatomic, retain) UIColor* tintColor; ++ (instancetype)itemWithAction:(XeniaSettingsHubAction)action + title:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name + tintColor:(UIColor*)tint_color; ++ (instancetype)infoItemWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name; +@end + +@implementation XeniaSettingsActionListItem + ++ (instancetype)itemWithAction:(XeniaSettingsHubAction)action + title:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name + tintColor:(UIColor*)tint_color { + XeniaSettingsActionListItem* item = + [[[XeniaSettingsActionListItem alloc] init] autorelease]; + item.action = action; + item.selectable = YES; + item.title = title; + item.subtitle = subtitle; + item.symbolName = symbol_name; + item.tintColor = tint_color; + return item; +} + ++ (instancetype)infoItemWithTitle:(NSString*)title + subtitle:(NSString*)subtitle + symbolName:(NSString*)symbol_name { + XeniaSettingsActionListItem* item = + [[[XeniaSettingsActionListItem alloc] init] autorelease]; + item.action = XeniaSettingsHubActionNone; + item.selectable = NO; + item.title = title; + item.subtitle = subtitle; + item.symbolName = symbol_name; + item.tintColor = [XeniaTheme sectionIconGray]; + return item; +} + +- (void)dealloc { + [_title release]; + [_subtitle release]; + [_symbolName release]; + [_tintColor release]; + [super dealloc]; +} + +@end + +@interface XeniaSettingsActionListViewController : XESheetTableViewController +- (instancetype)initWithTitle:(NSString*)title + items:(NSArray*)items + actionHandler:(XeniaSettingsHubActionHandler)action_handler; +@end + +@implementation XeniaSettingsActionListViewController { + NSArray* items_; + XeniaSettingsHubActionHandler action_handler_; +} + +- (instancetype)initWithTitle:(NSString*)title + items:(NSArray*)items + actionHandler:(XeniaSettingsHubActionHandler)action_handler { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + self.title = title; + items_ = [items retain]; + action_handler_ = [action_handler copy]; + } + return self; +} + +- (void)dealloc { + [items_ release]; + [action_handler_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 72.0; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)__unused section { + return static_cast(items_.count); +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaSettingsActionCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + cell.textLabel.numberOfLines = 1; + cell.detailTextLabel.numberOfLines = 3; + } + + XeniaSettingsActionListItem* item = [items_ objectAtIndex:indexPath.row]; + cell.textLabel.text = item.title; + cell.textLabel.textColor = item.selectable ? [XeniaTheme textPrimary] + : [XeniaTheme textSecondary]; + cell.detailTextLabel.text = item.subtitle; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(cell.textLabel, UIFontTextStyleBody, 17.0, + UIFontWeightSemibold); + xe_apply_label_font(cell.detailTextLabel, UIFontTextStyleSubheadline, 15.0, + UIFontWeightRegular); + cell.selectionStyle = item.selectable ? UITableViewCellSelectionStyleDefault + : UITableViewCellSelectionStyleNone; + cell.accessoryType = item.selectable ? UITableViewCellAccessoryDisclosureIndicator + : UITableViewCellAccessoryNone; + UIImageSymbolConfiguration* icon_config = + [UIImageSymbolConfiguration configurationWithPointSize:20.0 + weight:UIImageSymbolWeightSemibold]; + UIImage* image = [UIImage systemImageNamed:item.symbolName + withConfiguration:icon_config]; + cell.imageView.image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + cell.imageView.tintColor = item.tintColor; + XEApplyAccessibility(cell, item.title, item.subtitle, + item.selectable ? @"Performs this settings action." : nil, + item.selectable ? UIAccessibilityTraitButton + : UIAccessibilityTraitStaticText); + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + XeniaSettingsActionListItem* item = [items_ objectAtIndex:indexPath.row]; + if (!item.selectable || !action_handler_) { + return; + } + action_handler_(item.action); +} + +@end + +@interface XeniaSettingsControlsViewController : XESheetTableViewController +- (instancetype)initWithActionHandler:(XeniaSettingsHubActionHandler)action_handler; +@end + +@implementation XeniaSettingsControlsViewController { + NSArray* layout_items_; + XeniaSettingsHubActionHandler action_handler_; +} + +- (instancetype)initWithActionHandler:(XeniaSettingsHubActionHandler)action_handler { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + self.title = @"Controls"; + UIColor* blue = [XeniaTheme sectionIconBlue]; + layout_items_ = [@[ + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionChooseGameTouchLayout + title:@"Edit Game Touch Layout" + subtitle:@"Choose an installed title and edit its touch controls before launch." + symbolName:@"gamecontroller.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionOpenTouchLayoutLibrary + title:@"Layout Library" + subtitle:@"Browse, rename, duplicate, import, export, and reset touch layouts." + symbolName:@"rectangle.grid.2x2.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionEditTouchControls + title:@"Edit Running Touch Layout" + subtitle:@"Open the in-game touch editor for the currently running title." + symbolName:@"hand.draw.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionImportTouchLayout + title:@"Import Layout" + subtitle:@"Install a .toml layout from Files." + symbolName:@"square.and.arrow.down.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionExportTouchLayout + title:@"Export Running Layout" + subtitle:@"Share the layout currently loaded in the running game." + symbolName:@"square.and.arrow.up.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionResetTouchLayout + title:@"Reset Running Layout" + subtitle:@"Restore the running game's layout to the built-in XeniOS preset." + symbolName:@"arrow.counterclockwise" + tintColor:[XeniaTheme sectionIconOrange]], + ] retain]; + action_handler_ = [action_handler copy]; + } + return self; +} + +- (void)dealloc { + [layout_items_ release]; + [action_handler_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 72.0; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 2; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)section { + return section == 0 ? static_cast(layout_items_.count) : 1; +} + +- (NSString*)tableView:(UITableView* __unused)tableView + titleForHeaderInSection:(NSInteger)section { + return section == 0 ? @"Touch Layouts" : @"Touch Behavior"; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaSettingsControlsCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + cell.textLabel.numberOfLines = 1; + cell.detailTextLabel.numberOfLines = 3; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } + + NSString* title = nil; + NSString* subtitle = nil; + NSString* symbol_name = nil; + UIColor* tint_color = nil; + if (indexPath.section == 0) { + XeniaSettingsActionListItem* item = [layout_items_ objectAtIndex:indexPath.row]; + title = item.title; + subtitle = item.subtitle; + symbol_name = item.symbolName; + tint_color = item.tintColor; + } else { + title = @"Touch Behavior"; + subtitle = @"Haptics, overlay visibility, and touch-look tuning."; + symbol_name = @"hand.tap.fill"; + tint_color = [XeniaTheme sectionIconBlue]; + } + + cell.textLabel.text = title; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = subtitle; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(cell.textLabel, UIFontTextStyleBody, 17.0, + UIFontWeightSemibold); + xe_apply_label_font(cell.detailTextLabel, UIFontTextStyleSubheadline, 15.0, + UIFontWeightRegular); + + UIImageSymbolConfiguration* icon_config = + [UIImageSymbolConfiguration configurationWithPointSize:20.0 + weight:UIImageSymbolWeightSemibold]; + UIImage* image = [UIImage systemImageNamed:symbol_name + withConfiguration:icon_config]; + cell.imageView.image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + cell.imageView.tintColor = tint_color; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + XEApplyAccessibility(cell, title, subtitle, @"Opens this controls option.", + UIAccessibilityTraitButton); + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 0) { + XeniaSettingsActionListItem* item = [layout_items_ objectAtIndex:indexPath.row]; + if (action_handler_) { + action_handler_(item.action); + } + return; + } + + XeniaConfigViewController* config = [[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kControls + style:UITableViewStyleInsetGrouped]; + config.showsRootDismissButton = NO; + [self.navigationController pushViewController:config animated:YES]; + [config release]; +} + +@end + +@interface XeniaSettingsAboutViewController : XESheetTableViewController +@end + +@implementation XeniaSettingsAboutViewController + +- (instancetype)init { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + self.title = @"About"; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemBackgroundColor]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 2; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)section { + return section == 0 ? 3 : 4; +} + +- (NSString*)tableView:(UITableView* __unused)tableView +titleForHeaderInSection:(NSInteger)section { + return section == 0 ? @"Build" : @"Links"; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaSettingsAboutCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 + reuseIdentifier:kCellIdentifier] autorelease]; + } + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + + if (indexPath.section == 0) { + NSArray* titles = @[ @"Name", @"Version", @"Bundle" ]; + NSString* version = XeniaBundleValue(@"CFBundleShortVersionString") ?: @""; + NSString* build = XeniaBundleValue((NSString*)kCFBundleVersionKey) ?: @""; + NSString* version_text = version.length && build.length + ? [NSString stringWithFormat:@"%@ (%@)", version, build] + : (version.length ? version : build); + NSArray* values = @[ + XeniaBundleValue((NSString*)kCFBundleNameKey) ?: @"XeniOS", + version_text.length ? version_text : @"Development Build", + [[NSBundle mainBundle] bundleIdentifier] ?: @"" + ]; + cell.textLabel.text = [titles objectAtIndex:indexPath.row]; + cell.detailTextLabel.text = [values objectAtIndex:indexPath.row]; + return cell; + } + + NSArray* titles = @[ @"Website", @"GitHub", @"Discord", @"Support" ]; + cell.textLabel.text = [titles objectAtIndex:indexPath.row]; + cell.detailTextLabel.text = @""; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section != 1) { + return; + } + NSArray* urls = @[ + @"https://xenios.jp", + @"https://github.com/xenios-jp/XeniOS", + @"https://discord.gg/QwcTtNKTGf", + @"https://ko-fi.com/xenios", + ]; + XeniaOpenSettingsURL([urls objectAtIndex:indexPath.row]); +} + +@end + +@interface XeniaSettingsHubViewController () +- (UIViewController*)makeDetailControllerForItem:(XeniaSettingsHubItem*)item; +@end + +@implementation XeniaSettingsHubViewController { + xe::ui::IOSWindowedAppContext* app_context_; + XeniaSettingsStatusHandler on_status_; + XeniaSettingsHubActionHandler action_handler_; + XeniaSettingsInitialSection initial_section_; + NSArray* all_sections_; + NSArray* sections_; + UISearchController* search_controller_; + void (^dismissal_handler_)(void); + BOOL shows_close_button_; +} + +@synthesize dismissalHandler = dismissal_handler_; +@synthesize actionHandler = action_handler_; +@synthesize showsCloseButton = shows_close_button_; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status { + return [self initWithAppContext:app_context + onStatus:on_status + initialSection:XeniaSettingsInitialSectionMain]; +} + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status + initialSection:(XeniaSettingsInitialSection)initial_section { + self = [super initWithStyle:UITableViewStyleInsetGrouped]; + if (self) { + app_context_ = app_context; + on_status_ = [on_status copy]; + initial_section_ = initial_section; + all_sections_ = [[self buildSections] retain]; + sections_ = [all_sections_ retain]; + self.title = @"Settings"; + } + return self; +} + +- (void)dealloc { + [on_status_ release]; + [action_handler_ release]; + [all_sections_ release]; + [sections_ release]; + [search_controller_ release]; + [dismissal_handler_ release]; + [super dealloc]; +} + +- (NSArray*)buildSections { + UIColor* mint = [XeniaTheme accent]; + UIColor* blue = [XeniaTheme sectionIconBlue]; + UIColor* indigo = [XeniaTheme sectionIconIndigo]; + UIColor* orange = [XeniaTheme sectionIconOrange]; + UIColor* purple = [XeniaTheme sectionIconPurple]; + UIColor* red = [XeniaTheme sectionIconRed]; + UIColor* gray = [XeniaTheme sectionIconGray]; + + XeniaSettingsHubSection* account = + [XeniaSettingsHubSection sectionWithTitle:@"Account" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindProfile + title:@"Profile" + subtitle:@"Manage local profiles and sign-in slots." + symbolName:@"person.crop.circle.fill" + tintColor:mint], + ]]; + + XeniaSettingsHubSection* general = + [XeniaSettingsHubSection sectionWithTitle:@"General" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindDisplay + title:@"Display" + subtitle:@"Presenter output, aspect, and frame pacing." + symbolName:@"display" + tintColor:indigo], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindGraphics + title:@"Graphics" + subtitle:@"Backend, quality, shader cache, and render target path." + symbolName:@"gearshape.2.fill" + tintColor:purple], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindAudio + title:@"Audio" + subtitle:@"Output and decoder preferences." + symbolName:@"speaker.wave.2.fill" + tintColor:orange], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindControls + title:@"Controls" + subtitle:@"Touch layouts, haptics, overlay visibility, and look tuning." + symbolName:@"gamecontroller.fill" + tintColor:blue], + ]]; + + XeniaSettingsHubSection* performance = + [XeniaSettingsHubSection sectionWithTitle:@"Performance" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindPerformance + title:@"Caching & QoS" + subtitle:@"Shader compilation, caches, and experimental thread priority." + symbolName:@"bolt.fill" + tintColor:orange], + ]]; + + XeniaSettingsHubSection* compatibility = + [XeniaSettingsHubSection sectionWithTitle:@"Compatibility" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindCompatibility + title:@"GPU Workarounds" + subtitle:@"Per-title GPU, memory, boot, and JIT compatibility tweaks." + symbolName:@"wrench.and.screwdriver.fill" + tintColor:red], + ]]; + + XeniaSettingsHubSection* diagnostics = + [XeniaSettingsHubSection sectionWithTitle:@"Diagnostics & Advanced" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindDiagnostics + title:@"Diagnostics" + subtitle:@"Log level and live log viewer." + symbolName:@"waveform.path.ecg" + tintColor:red], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindAdvancedDebug + title:@"Advanced Debug" + subtitle:@"Curated F8-style debug and compatibility overrides." + symbolName:@"slider.horizontal.3" + tintColor:purple], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindAllCvars + title:@"All Config" + subtitle:@"Search and edit every registered cvar." + symbolName:@"tablecells" + tintColor:purple], + ]]; + + XeniaSettingsHubSection* system = + [XeniaSettingsHubSection sectionWithTitle:@"System" + items:@[ + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindLibrary + title:@"Library & Storage" + subtitle:@"Imports, content, caches, and local files." + symbolName:@"externaldrive.fill" + tintColor:gray], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindAutomation + title:@"Automation" + subtitle:@"Local iOS automation options." + symbolName:@"wand.and.stars" + tintColor:mint], + [XeniaSettingsHubItem + itemWithKind:XeniaSettingsHubCategoryKindAbout + title:@"About" + subtitle:@"Build information and project links." + symbolName:@"info.circle.fill" + tintColor:gray], + ]]; + + return @[ account, general, performance, compatibility, diagnostics, system ]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 16.0, 0, 16.0); + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 74.0; + self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeAlways; + search_controller_ = [[UISearchController alloc] initWithSearchResultsController:nil]; + search_controller_.searchResultsUpdater = self; + search_controller_.obscuresBackgroundDuringPresentation = NO; + search_controller_.hidesNavigationBarDuringPresentation = NO; + search_controller_.searchBar.placeholder = @"Search Settings"; + self.navigationItem.searchController = search_controller_; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; + if (shows_close_button_) { + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithTitle:@"Close" + style:UIBarButtonItemStylePlain + target:self + action:@selector(closeTapped:)] autorelease]; + } +} + +- (void)closeTapped:(id)sender { + (void)sender; + void (^dismissal_handler)(void) = [dismissal_handler_ copy]; + [self dismissViewControllerAnimated:YES + completion:^{ + if (dismissal_handler) { + dismissal_handler(); + } + [dismissal_handler release]; + }]; +} + +- (void)replaceVisibleSections:(NSArray*)sections { + [sections_ release]; + sections_ = [sections retain]; + [self.tableView reloadData]; +} + +- (void)appendTouchLayoutSearchResults:(NSMutableArray*)results query:(NSString*)query { + UIColor* blue = [XeniaTheme sectionIconBlue]; + NSArray* items = @[ + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Edit Game Touch Layout" + subtitle:@"Controls · Choose an installed title and edit its touch controls before launch." + symbolName:@"gamecontroller.fill" + tintColor:blue], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Layout Library" + subtitle:@"Controls · Browse, rename, duplicate, import, export, and reset touch layouts." + symbolName:@"rectangle.grid.2x2.fill" + tintColor:blue], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Edit Running Touch Layout" + subtitle:@"Controls · Open the in-game touch editor for the currently running title." + symbolName:@"hand.draw.fill" + tintColor:blue], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Import Layout" + subtitle:@"Controls · Install a .toml layout from Files." + symbolName:@"square.and.arrow.down.fill" + tintColor:blue], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Export Running Layout" + subtitle:@"Controls · Share the layout currently loaded in the running game." + symbolName:@"square.and.arrow.up.fill" + tintColor:blue], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindTouchLayouts + title:@"Reset Running Layout" + subtitle:@"Controls · Restore the running game's layout to the built-in XeniOS preset." + symbolName:@"arrow.counterclockwise" + tintColor:[XeniaTheme sectionIconOrange]], + ]; + for (XeniaSettingsHubItem* item in items) { + if (XeniaSettingsHubItemMatches(item, @"Controls", query)) { + [results addObject:item]; + } + } +} + +- (void)appendLibrarySearchResults:(NSMutableArray*)results query:(NSString*)query { + NSArray* items = @[ + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindLibrary + title:@"Import Game" + subtitle:@"Library & Storage · Open Files and copy a game or content package into XeniOS storage." + symbolName:@"plus.app.fill" + tintColor:[XeniaTheme accent]], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindLibrary + title:@"Refresh Library" + subtitle:@"Library & Storage · Rescan XeniOS storage and update the launcher grid." + symbolName:@"arrow.clockwise" + tintColor:[XeniaTheme sectionIconGray]], + [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindLibrary + title:@"Game Content and Patches" + subtitle:@"Library & Storage · Manage title-specific content from a game tile." + symbolName:@"puzzlepiece.extension.fill" + tintColor:[XeniaTheme sectionIconGray]], + ]; + for (XeniaSettingsHubItem* item in items) { + if (XeniaSettingsHubItemMatches(item, @"Library & Storage", query)) { + [results addObject:item]; + } + } +} + +- (void)updateSearchResultsForSearchController:(UISearchController*)searchController { + NSString* query = + [searchController.searchBar.text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (!query.length) { + [self replaceVisibleSections:all_sections_]; + return; + } + + NSMutableArray* filtered_sections = [NSMutableArray array]; + for (XeniaSettingsHubSection* section in all_sections_) { + NSMutableArray* filtered_items = [NSMutableArray array]; + for (XeniaSettingsHubItem* item in section.items) { + if (XeniaSettingsHubItemMatches(item, section.title, query)) { + [filtered_items addObject:item]; + } + } + if (filtered_items.count) { + [filtered_sections addObject:[XeniaSettingsHubSection sectionWithTitle:section.title + items:filtered_items]]; + } + } + + NSMutableArray* setting_results = [NSMutableArray array]; + [self appendTouchLayoutSearchResults:setting_results query:query]; + [self appendLibrarySearchResults:setting_results query:query]; + const IOSConfigCatalogKind kCatalogs[] = { + IOSConfigCatalogKind::kDisplay, IOSConfigCatalogKind::kGraphics, + IOSConfigCatalogKind::kAudio, IOSConfigCatalogKind::kControls, + IOSConfigCatalogKind::kPerformance, IOSConfigCatalogKind::kCompatibility, + IOSConfigCatalogKind::kDiagnostics, IOSConfigCatalogKind::kDebugSettings, + IOSConfigCatalogKind::kSystem, + }; + for (IOSConfigCatalogKind kind : kCatalogs) { + XeniaAppendCatalogSearchResults(setting_results, kind, query); + } + if (setting_results.count) { + [filtered_sections addObject:[XeniaSettingsHubSection sectionWithTitle:@"Settings" + items:setting_results]]; + } + + [self replaceVisibleSections:filtered_sections]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return static_cast(sections_.count); +} + +- (NSInteger)tableView:(UITableView* __unused)tableView + numberOfRowsInSection:(NSInteger)section { + XeniaSettingsHubSection* hub_section = [self sectionAtIndex:section]; + return static_cast(hub_section.items.count); +} + +- (NSString*)tableView:(UITableView* __unused)tableView + titleForHeaderInSection:(NSInteger)section { + return [self sectionAtIndex:section].title; +} + +- (XeniaSettingsHubSection*)sectionAtIndex:(NSInteger)section { + if (section < 0 || section >= static_cast(sections_.count)) { + return nil; + } + return [sections_ objectAtIndex:section]; +} + +- (XeniaSettingsHubItem*)itemAtIndexPath:(NSIndexPath*)indexPath { + XeniaSettingsHubSection* section = [self sectionAtIndex:indexPath.section]; + if (!section || indexPath.row < 0 || + indexPath.row >= static_cast(section.items.count)) { + return nil; + } + return [section.items objectAtIndex:indexPath.row]; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kCellIdentifier = @"XeniaSettingsHubCell"; + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (!cell) { + cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kCellIdentifier] autorelease]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.textLabel.numberOfLines = 1; + cell.detailTextLabel.numberOfLines = 2; + } + + XeniaSettingsHubItem* item = [self itemAtIndexPath:indexPath]; + if (!item) { + cell.textLabel.text = @""; + cell.detailTextLabel.text = @""; + cell.imageView.image = nil; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.isAccessibilityElement = NO; + return cell; + } + + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + cell.textLabel.text = item.title; + cell.textLabel.textColor = [XeniaTheme textPrimary]; + cell.detailTextLabel.text = item.subtitle; + cell.detailTextLabel.textColor = [XeniaTheme textSecondary]; + xe_apply_label_font(cell.textLabel, UIFontTextStyleBody, 17.0, + UIFontWeightSemibold); + xe_apply_label_font(cell.detailTextLabel, UIFontTextStyleSubheadline, 15.0, + UIFontWeightRegular); + + UIImageSymbolConfiguration* image_config = + [UIImageSymbolConfiguration configurationWithPointSize:20.0 + weight:UIImageSymbolWeightSemibold]; + UIImage* image = [UIImage systemImageNamed:item.symbolName + withConfiguration:image_config]; + cell.imageView.image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + cell.imageView.tintColor = item.tintColor; + + NSString* value = item.subtitle.length ? item.subtitle : nil; + XEApplyAccessibility(cell, item.title, value, @"Opens this settings section.", + UIAccessibilityTraitButton); + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + XeniaSettingsHubItem* item = [self itemAtIndexPath:indexPath]; + UIViewController* detail = [self makeDetailControllerForItem:item]; + if (!detail) { + return; + } + if (search_controller_.active) { + search_controller_.active = NO; + } + + if ([self.selectionDelegate respondsToSelector: + @selector(settingsHubViewController:didSelectController:)]) { + [self.selectionDelegate settingsHubViewController:self didSelectController:detail]; + return; + } + [self.navigationController pushViewController:detail animated:YES]; +} + +- (UIViewController*)makeInitialDetailController { + XeniaSettingsHubItem* item = nil; + if (initial_section_ == XeniaSettingsInitialSectionProfile) { + item = [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindProfile + title:@"Profile" + subtitle:@"Manage local profiles and sign-in slots." + symbolName:@"person.crop.circle.fill" + tintColor:[XeniaTheme accent]]; + } else { + item = [XeniaSettingsHubItem itemWithKind:XeniaSettingsHubCategoryKindDisplay + title:@"Display" + subtitle:@"Presenter output, aspect, and frame pacing." + symbolName:@"display" + tintColor:[XeniaTheme sectionIconIndigo]]; + } + return [self makeDetailControllerForItem:item]; +} + +- (UIViewController*)makeDetailControllerForItem:(XeniaSettingsHubItem*)item { + if (!item) { + return nil; + } + + switch (item.kind) { + case XeniaSettingsHubCategoryKindProfile: { + XeniaProfileViewController* profile = + [[[XeniaProfileViewController alloc] initWithAppContext:app_context_ + onStatus:on_status_] autorelease]; + profile.showsDismissButton = NO; + return profile; + } + case XeniaSettingsHubCategoryKindControls: + return [[[XeniaSettingsControlsViewController alloc] + initWithActionHandler:action_handler_] autorelease]; + case XeniaSettingsHubCategoryKindTouchLayouts: { + UIColor* blue = [XeniaTheme sectionIconBlue]; + NSArray* items = @[ + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionChooseGameTouchLayout + title:@"Edit Game Touch Layout" + subtitle:@"Choose an installed title and edit its touch controls before launch." + symbolName:@"gamecontroller.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionOpenTouchLayoutLibrary + title:@"Layout Library" + subtitle:@"Browse, rename, duplicate, import, export, and reset touch layouts." + symbolName:@"rectangle.grid.2x2.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionEditTouchControls + title:@"Edit Running Touch Layout" + subtitle:@"Open the in-game touch editor for the currently running title." + symbolName:@"hand.draw.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionImportTouchLayout + title:@"Import Layout" + subtitle:@"Install a .toml layout from Files." + symbolName:@"square.and.arrow.down.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionExportTouchLayout + title:@"Export Running Layout" + subtitle:@"Share the layout currently loaded in the running game." + symbolName:@"square.and.arrow.up.fill" + tintColor:blue], + [XeniaSettingsActionListItem + itemWithAction:XeniaSettingsHubActionResetTouchLayout + title:@"Reset Running Layout" + subtitle:@"Restore the running game's layout to the built-in XeniOS preset." + symbolName:@"arrow.counterclockwise" + tintColor:[XeniaTheme sectionIconOrange]], + ]; + return [[[XeniaSettingsActionListViewController alloc] + initWithTitle:@"Touch Layouts" + items:items + actionHandler:action_handler_] autorelease]; + } + case XeniaSettingsHubCategoryKindTouchBehavior: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kControls + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindDisplay: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kDisplay + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindGraphics: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kGraphics + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindAudio: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kAudio + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindLibrary: { + UIColor* gray = [XeniaTheme sectionIconGray]; + NSArray* items = @[ + [XeniaSettingsActionListItem itemWithAction:XeniaSettingsHubActionImportGame + title:@"Import Game" + subtitle:@"Open Files and copy a game or content package into XeniOS storage." + symbolName:@"plus.app.fill" + tintColor:[XeniaTheme accent]], + [XeniaSettingsActionListItem itemWithAction:XeniaSettingsHubActionRefreshLibrary + title:@"Refresh Library" + subtitle:@"Rescan XeniOS storage and update the launcher grid." + symbolName:@"arrow.clockwise" + tintColor:gray], + [XeniaSettingsActionListItem + infoItemWithTitle:@"Game Content and Patches" + subtitle:@"Manage title-specific installed content, discs, and patches from a game tile's context menu." + symbolName:@"puzzlepiece.extension.fill"], + ]; + return [[[XeniaSettingsActionListViewController alloc] + initWithTitle:@"Library & Storage" + items:items + actionHandler:action_handler_] autorelease]; + } + case XeniaSettingsHubCategoryKindPerformance: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kPerformance + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindCompatibility: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kCompatibility + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindDiagnostics: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kDiagnostics + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindAdvancedDebug: { + XeniaIOSDebugSettingsViewController* config = + [[[XeniaIOSDebugSettingsViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kDebugSettings + liveOverride:NO] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindAutomation: { + XeniaConfigViewController* config = [[[XeniaConfigViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kSystem + style:UITableViewStyleInsetGrouped] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + case XeniaSettingsHubCategoryKindAbout: + return [[[XeniaSettingsAboutViewController alloc] init] autorelease]; + case XeniaSettingsHubCategoryKindAllCvars: { + XeniaIOSDebugSettingsViewController* config = + [[[XeniaIOSDebugSettingsViewController alloc] + initWithCatalogKind:IOSConfigCatalogKind::kAllCvars + liveOverride:NO] autorelease]; + config.showsRootDismissButton = NO; + return config; + } + default: + return nil; + } +} + +@end + +@interface XeniaSettingsWorkspaceViewController () < + XeniaSettingsHubViewControllerDelegate> +@end + +@implementation XeniaSettingsWorkspaceViewController { + XeniaSettingsHubViewController* hub_controller_; + void (^dismissal_handler_)(void); + XeniaSettingsHubActionHandler action_handler_; +} + +@synthesize dismissalHandler = dismissal_handler_; +@synthesize actionHandler = action_handler_; + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status { + return [self initWithAppContext:app_context + onStatus:on_status + initialSection:XeniaSettingsInitialSectionMain]; +} + +- (instancetype)initWithAppContext:(xe::ui::IOSWindowedAppContext*)app_context + onStatus:(XeniaSettingsStatusHandler)on_status + initialSection:(XeniaSettingsInitialSection)initial_section { + self = [super initWithStyle:UISplitViewControllerStyleDoubleColumn]; + if (self) { + self.title = @"Settings"; + self.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; + self.preferredSplitBehavior = UISplitViewControllerSplitBehaviorTile; + + XeniaSettingsHubViewController* hub = + [[[XeniaSettingsHubViewController alloc] initWithAppContext:app_context + onStatus:on_status + initialSection:initial_section] autorelease]; + hub.selectionDelegate = self; + hub.showsCloseButton = YES; + hub.dismissalHandler = dismissal_handler_; + hub_controller_ = [hub retain]; + + UINavigationController* primary = + [[[UINavigationController alloc] initWithRootViewController:hub] autorelease]; + primary.navigationBar.prefersLargeTitles = YES; + + UIViewController* detail = [hub makeInitialDetailController]; + UINavigationController* secondary = + [[[UINavigationController alloc] initWithRootViewController:detail] autorelease]; + secondary.navigationBar.prefersLargeTitles = NO; + + [self setViewController:primary forColumn:UISplitViewControllerColumnPrimary]; + [self setViewController:secondary forColumn:UISplitViewControllerColumnSecondary]; + } + return self; +} + +- (void)dealloc { + [hub_controller_ release]; + [dismissal_handler_ release]; + [action_handler_ release]; + [super dealloc]; +} + +- (void)setDismissalHandler:(void (^)(void))dismissalHandler { + if (dismissal_handler_ == dismissalHandler) { + return; + } + [dismissal_handler_ release]; + dismissal_handler_ = [dismissalHandler copy]; + hub_controller_.dismissalHandler = dismissal_handler_; +} + +- (void)setActionHandler:(XeniaSettingsHubActionHandler)actionHandler { + if (action_handler_ == actionHandler) { + return; + } + [action_handler_ release]; + action_handler_ = [actionHandler copy]; + hub_controller_.actionHandler = action_handler_; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskAll; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return xe_current_interface_orientation(self.view); +} + +- (BOOL)shouldAutorotate { + return YES; +} + +- (void)settingsHubViewController:(XeniaSettingsHubViewController* __unused)hub + didSelectController:(UIViewController*)controller { + UINavigationController* secondary = + [[[UINavigationController alloc] initWithRootViewController:controller] autorelease]; + secondary.navigationBar.prefersLargeTitles = NO; + [self setViewController:secondary forColumn:UISplitViewControllerColumnSecondary]; +} + +@end diff --git a/src/xenia/ui/ios/shared/apple_theme_tokens.cc b/src/xenia/ui/ios/shared/apple_theme_tokens.cc new file mode 100644 index 000000000..deee5a094 --- /dev/null +++ b/src/xenia/ui/ios/shared/apple_theme_tokens.cc @@ -0,0 +1,184 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/shared/apple_theme_tokens.h" + +namespace xe { +namespace ui { +namespace apple { + +namespace { + +// Non-color tokens are theme-independent — same values in dark and light. They +// live inside ThemeTokens for uniform access (`tokens.spacing.lg` etc.) and +// are repeated below in each variant. +constexpr ThemeShadowTokens kSharedShadows = { + /* subtle */ {/*radius*/ 8.0f, /*dark*/ 0.14f, /*light*/ 0.10f, 0.0f, + 8.0f}, + /* medium */ + {/*radius*/ 14.0f, /*dark*/ 0.42f, /*light*/ 0.18f, 0.0f, 8.0f}, + /* elevated */ + {/*radius*/ 18.0f, /*dark*/ 0.55f, /*light*/ 0.22f, 0.0f, 10.0f}, +}; + +constexpr ThemeSpacingTokens kSharedSpacing = { + /* xs */ 4.0f, + /* sm */ 8.0f, + /* md */ 12.0f, + /* lg */ 16.0f, + /* xl */ 24.0f, + /* xxl */ 32.0f, +}; + +constexpr ThemeOpacityTokens kSharedOpacity = { + /* subtle */ 0.06f, + /* soft */ 0.10f, + /* medium */ 0.16f, + /* strong */ 0.28f, + /* heavy */ 0.42f, +}; + +// UIFontWeight values: regular=0.0, medium=0.23, semibold=0.3. +constexpr ThemeTypographyScale kSharedTypographyScale = { + /* caption2 */ {10.0f, 0.23f}, + /* caption1 */ {12.0f, 0.0f}, + /* footnote */ {13.0f, 0.0f}, + /* subheadline */ {15.0f, 0.0f}, + /* body */ {17.0f, 0.0f}, + /* headline */ {17.0f, 0.3f}, + /* title3 */ {20.0f, 0.3f}, + /* title2 */ {22.0f, 0.3f}, +}; + +constexpr ThemeRadiusTokens kSharedRadius = { + /* xs */ 4.0f, + /* md */ 8.0f, + /* lg */ 12.0f, + /* xl */ 16.0f, + /* xxl */ 24.0f, +}; + +constexpr ThemeTokens kThemeDark = { + ThemeColorTokens{ + /* bg_primary */ {0x09, 0x09, 0x0B, 0xFF}, + /* bg_surface */ {0x18, 0x18, 0x1B, 0xFF}, + /* bg_surface_2 */ {0x27, 0x27, 0x2A, 0xFF}, + /* bg_surface_3 */ {0x3F, 0x3F, 0x46, 0xFF}, + /* text_primary */ {0xFA, 0xFA, 0xFA, 0xFF}, + /* text_secondary */ {0xA1, 0xA1, 0xAA, 0xFF}, + /* text_muted */ {0x71, 0x71, 0x7A, 0xFF}, + /* accent */ {0x34, 0xD3, 0x99, 0xFF}, + /* accent_hover */ {0x6E, 0xE7, 0xB7, 0xFF}, + /* accent_fg */ {0x09, 0x09, 0x0B, 0xFF}, + // Website uses oklch(1 0 0 / 0.06) and oklch(1 0 0 / 0.1). + /* border */ {0xFF, 0xFF, 0xFF, 15}, + /* border_hover */ {0xFF, 0xFF, 0xFF, 26}, + /* status_playable */ {0x34, 0xD3, 0x99, 0xFF}, + /* status_ingame */ {0x60, 0xA5, 0xFA, 0xFF}, + /* status_intro */ {0xFB, 0xBF, 0x24, 0xFF}, + /* status_loads */ {0xFB, 0x92, 0x3C, 0xFF}, + /* status_nothing */ {0xF8, 0x71, 0x71, 0xFF}, + /* overlay */ {0x00, 0x00, 0x00, 217}, + /* overlay_light */ {0x00, 0x00, 0x00, 148}, + }, + ThemeTypographyTokens{ + /* font_sans */ "Inter", + /* font_mono */ "JetBrains Mono", + }, + kSharedRadius, + kSharedShadows, + kSharedSpacing, + kSharedOpacity, + kSharedTypographyScale, + ThemeSectionIconTokens{ + // Branded equivalents for the system color palette previously used in + // the settings hub. Picked to read as the same categories while + // sitting next to the mint accent without clashing. + /* blue */ {0x4F, 0x8C, 0xFF, 0xFF}, + /* indigo */ {0x81, 0x8C, 0xF8, 0xFF}, + /* orange */ {0xF5, 0x9E, 0x0B, 0xFF}, + /* purple */ {0xA8, 0x55, 0xF7, 0xFF}, + /* red */ {0xF8, 0x71, 0x71, 0xFF}, + /* gray */ {0x71, 0x71, 0x7A, 0xFF}, + }, +}; + +constexpr ThemeTokens kThemeLight = { + ThemeColorTokens{ + /* bg_primary */ {0xFF, 0xFF, 0xFF, 0xFF}, + /* bg_surface */ {0xF4, 0xF4, 0xF5, 0xFF}, + /* bg_surface_2 */ {0xE4, 0xE4, 0xE7, 0xFF}, + /* bg_surface_3 */ {0xD4, 0xD4, 0xD8, 0xFF}, + /* text_primary */ {0x09, 0x09, 0x0B, 0xFF}, + /* text_secondary */ {0x52, 0x52, 0x5B, 0xFF}, + /* text_muted */ {0xA1, 0xA1, 0xAA, 0xFF}, + /* accent */ {0x10, 0xB9, 0x81, 0xFF}, + /* accent_hover */ {0x05, 0x96, 0x69, 0xFF}, + /* accent_fg */ {0xFF, 0xFF, 0xFF, 0xFF}, + // Website uses oklch(0 0 0 / 0.08) and oklch(0 0 0 / 0.12). + /* border */ {0x00, 0x00, 0x00, 20}, + /* border_hover */ {0x00, 0x00, 0x00, 31}, + /* status_playable */ {0x34, 0xD3, 0x99, 0xFF}, + /* status_ingame */ {0x60, 0xA5, 0xFA, 0xFF}, + /* status_intro */ {0xFB, 0xBF, 0x24, 0xFF}, + /* status_loads */ {0xFB, 0x92, 0x3C, 0xFF}, + /* status_nothing */ {0xF8, 0x71, 0x71, 0xFF}, + /* overlay */ {0x00, 0x00, 0x00, 217}, + /* overlay_light */ {0x00, 0x00, 0x00, 148}, + }, + ThemeTypographyTokens{ + /* font_sans */ "Inter", + /* font_mono */ "JetBrains Mono", + }, + kSharedRadius, + kSharedShadows, + kSharedSpacing, + kSharedOpacity, + kSharedTypographyScale, + ThemeSectionIconTokens{ + // Darker variants for the same categorical palette on light + // backgrounds. + /* blue */ {0x25, 0x63, 0xEB, 0xFF}, + /* indigo */ {0x4F, 0x46, 0xE5, 0xFF}, + /* orange */ {0xD9, 0x77, 0x06, 0xFF}, + /* purple */ {0x7C, 0x3A, 0xED, 0xFF}, + /* red */ {0xDC, 0x26, 0x26, 0xFF}, + /* gray */ {0x71, 0x71, 0x7A, 0xFF}, + }, +}; + +// Touch overlay tints — single set, matching the historical RGB values that +// were previously duplicated across touch_overlay_style_ios.mm:22-41 and +// touch_layout_library_ios.mm:243-261. These render over game content and +// do not follow the iOS dark/light trait collection. +constexpr ThemeTouchTintTokens kTouchTints = { + /* amber */ {0xF5, 0xBA, 0x4F, 0xFF}, // 0.96, 0.73, 0.31 + /* sky */ {0x57, 0xB8, 0xFA, 0xFF}, // 0.34, 0.72, 0.98 + /* mint */ {0x61, 0xE8, 0xB8, 0xFF}, // 0.38, 0.91, 0.72 + /* rose */ {0xFA, 0x7A, 0x99, 0xFF}, // 0.98, 0.48, 0.60 + /* lime */ {0xA8, 0xE3, 0x3D, 0xFF}, // 0.66, 0.89, 0.24 + /* coral */ {0xFA, 0x8F, 0x66, 0xFF}, // 0.98, 0.56, 0.40 + /* slate */ {0x99, 0xAB, 0xC4, 0xFF}, // 0.60, 0.67, 0.77 +}; + +} // namespace + +const ThemeTokens& GetThemeTokens(ThemeVariant variant) { + return variant == ThemeVariant::kLight ? kThemeLight : kThemeDark; +} + +const ThemeTokens& GetDarkThemeTokens() { return kThemeDark; } + +const ThemeTokens& GetLightThemeTokens() { return kThemeLight; } + +const ThemeTouchTintTokens& GetTouchTintTokens() { return kTouchTints; } + +} // namespace apple +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/shared/apple_theme_tokens.h b/src/xenia/ui/ios/shared/apple_theme_tokens.h new file mode 100644 index 000000000..73a2b719b --- /dev/null +++ b/src/xenia/ui/ios/shared/apple_theme_tokens.h @@ -0,0 +1,175 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_APPLE_THEME_TOKENS_H_ +#define XENIA_UI_APPLE_THEME_TOKENS_H_ + +#include + +namespace xe { +namespace ui { +namespace apple { + +// Snapshot source: the xenios-website repo's `src/app/globals.css`. +// Keep this module as the canonical token definition for Apple-native UI. +enum class ThemeVariant : uint8_t { + kDark = 0, + kLight = 1, +}; + +struct ColorRGBA8 { + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t a; + + constexpr float Rf() const { return float(r) / 255.0f; } + constexpr float Gf() const { return float(g) / 255.0f; } + constexpr float Bf() const { return float(b) / 255.0f; } + constexpr float Af() const { return float(a) / 255.0f; } +}; + +struct ThemeColorTokens { + ColorRGBA8 bg_primary; + ColorRGBA8 bg_surface; + ColorRGBA8 bg_surface_2; + ColorRGBA8 bg_surface_3; + ColorRGBA8 text_primary; + ColorRGBA8 text_secondary; + ColorRGBA8 text_muted; + ColorRGBA8 accent; + ColorRGBA8 accent_hover; + ColorRGBA8 accent_fg; + ColorRGBA8 border; + ColorRGBA8 border_hover; + ColorRGBA8 status_playable; + ColorRGBA8 status_ingame; + ColorRGBA8 status_intro; + ColorRGBA8 status_loads; + ColorRGBA8 status_nothing; + ColorRGBA8 overlay; + ColorRGBA8 overlay_light; +}; + +struct ThemeTypographyTokens { + const char* font_sans; + const char* font_mono; +}; + +struct ThemeRadiusTokens { + float xs; + float md; + float lg; + float xl; + float xxl; +}; + +// Shadow recipe. shadowColor is always black; the trait-aware alpha lives in +// opacity_dark / opacity_light so a layer can pick the right value at the +// trait collection it was instantiated under. +struct ThemeShadow { + float radius; + float opacity_dark; + float opacity_light; + float offset_x; + float offset_y; +}; + +struct ThemeShadowTokens { + ThemeShadow subtle; // floating window chrome + ThemeShadow medium; // status toast / focus glow + ThemeShadow elevated; // achievement notification / sheet stack +}; + +struct ThemeSpacingTokens { + float xs; + float sm; + float md; + float lg; + float xl; + float xxl; +}; + +struct ThemeOpacityTokens { + float subtle; + float soft; + float medium; + float strong; + float heavy; +}; + +// Each entry pairs a point size with a UIFontWeight value (which is a CGFloat +// typedef on Apple platforms: regular=0.0, medium=0.23, semibold=0.3, +// bold=0.4). The Objective-C side maps each style to a UIFontTextStyle for +// Dynamic Type scaling. +struct ThemeTypographyStyle { + float point_size; + float weight; +}; + +struct ThemeTypographyScale { + ThemeTypographyStyle caption2; // 10pt medium + ThemeTypographyStyle caption1; // 12pt regular + ThemeTypographyStyle footnote; // 13pt regular + ThemeTypographyStyle subheadline; // 15pt regular + ThemeTypographyStyle body; // 17pt regular + ThemeTypographyStyle headline; // 17pt semibold + ThemeTypographyStyle title3; // 20pt semibold + ThemeTypographyStyle title2; // 22pt semibold +}; + +// Branded categorical icon palette — Xenia equivalents for the system colors +// previously used by the settings hub +// (systemBlue/Indigo/Orange/Purple/Red/Gray). Variant-aware so each picks the +// correct shade for dark and light traits. +struct ThemeSectionIconTokens { + ColorRGBA8 blue; + ColorRGBA8 indigo; + ColorRGBA8 orange; + ColorRGBA8 purple; + ColorRGBA8 red; + ColorRGBA8 gray; +}; + +// On-screen touch overlay tint palette. The same seven colours that +// IOSTouchTintStyle enumerates. Not theme-variant-aware because the overlay +// renders above game content rather than above iOS chrome. +struct ThemeTouchTintTokens { + ColorRGBA8 amber; + ColorRGBA8 sky; + ColorRGBA8 mint; + ColorRGBA8 rose; + ColorRGBA8 lime; + ColorRGBA8 coral; + ColorRGBA8 slate; +}; + +struct ThemeTokens { + ThemeColorTokens colors; + ThemeTypographyTokens typography; + ThemeRadiusTokens radius; + ThemeShadowTokens shadows; + ThemeSpacingTokens spacing; + ThemeOpacityTokens opacity; + ThemeTypographyScale typography_scale; + ThemeSectionIconTokens section_icons; +}; + +const ThemeTokens& GetThemeTokens(ThemeVariant variant); +const ThemeTokens& GetDarkThemeTokens(); +const ThemeTokens& GetLightThemeTokens(); + +// Variant-independent — touch overlay colours don't follow iOS dark/light. +const ThemeTouchTintTokens& GetTouchTintTokens(); + +} // namespace apple +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_APPLE_THEME_TOKENS_H_ diff --git a/src/xenia/ui/ios/shared/apple_ui_navigation.cc b/src/xenia/ui/ios/shared/apple_ui_navigation.cc new file mode 100644 index 000000000..c6ee797e6 --- /dev/null +++ b/src/xenia/ui/ios/shared/apple_ui_navigation.cc @@ -0,0 +1,265 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/shared/apple_ui_navigation.h" + +#include +#include + +namespace xe { +namespace ui { +namespace apple { + +bool ControllerActionSet::Any() const { + return navigate_up || navigate_down || navigate_left || navigate_right || + accept || back || context || quick_action || guide || section_prev || + section_next || page_prev || page_next; +} + +void ControllerActionSet::SetDirection(NavigationDirection direction) { + switch (direction) { + case NavigationDirection::kUp: + navigate_up = true; + break; + case NavigationDirection::kDown: + navigate_down = true; + break; + case NavigationDirection::kLeft: + navigate_left = true; + break; + case NavigationDirection::kRight: + navigate_right = true; + break; + } +} + +ControllerNavigationMapper::ControllerNavigationMapper( + ControllerNavigationConfig config) + : config_(config) {} + +ControllerActionSet ControllerNavigationMapper::Update( + const hid::X_INPUT_STATE& state, uint64_t now_ms) { + const hid::X_INPUT_GAMEPAD& gamepad = state.gamepad; + ControllerActionSet actions = BuildEdgeActions(gamepad); + + const ActiveDirection direction = + ResolveDirection(gamepad, config_.stick_deadzone); + UpdateDirectionalRepeat(direction, now_ms, actions); + + prev_buttons_ = gamepad.buttons; + prev_left_trigger_pressed_ = + gamepad.left_trigger >= config_.trigger_threshold; + prev_right_trigger_pressed_ = + gamepad.right_trigger >= config_.trigger_threshold; + + return actions; +} + +void ControllerNavigationMapper::Reset() { + prev_buttons_ = 0; + prev_left_trigger_pressed_ = false; + prev_right_trigger_pressed_ = false; + repeat_active_ = false; + repeat_direction_ = NavigationDirection::kUp; + repeat_source_ = DirectionalSource::kNone; + next_repeat_ms_ = 0; +} + +ControllerNavigationMapper::ActiveDirection +ControllerNavigationMapper::ResolveDirection( + const hid::X_INPUT_GAMEPAD& gamepad, int16_t stick_deadzone) { + const bool dpad_up = (gamepad.buttons & hid::X_INPUT_GAMEPAD_DPAD_UP) != 0; + const bool dpad_down = + (gamepad.buttons & hid::X_INPUT_GAMEPAD_DPAD_DOWN) != 0; + const bool dpad_left = + (gamepad.buttons & hid::X_INPUT_GAMEPAD_DPAD_LEFT) != 0; + const bool dpad_right = + (gamepad.buttons & hid::X_INPUT_GAMEPAD_DPAD_RIGHT) != 0; + + if (dpad_up) { + return {true, NavigationDirection::kUp, DirectionalSource::kDpad}; + } + if (dpad_down) { + return {true, NavigationDirection::kDown, DirectionalSource::kDpad}; + } + if (dpad_left) { + return {true, NavigationDirection::kLeft, DirectionalSource::kDpad}; + } + if (dpad_right) { + return {true, NavigationDirection::kRight, DirectionalSource::kDpad}; + } + + const int16_t lx = gamepad.thumb_lx; + const int16_t ly = gamepad.thumb_ly; + const int32_t abs_lx = std::abs(static_cast(lx)); + const int32_t abs_ly = std::abs(static_cast(ly)); + + if (abs_lx < stick_deadzone && abs_ly < stick_deadzone) { + return {}; + } + + if (abs_ly >= abs_lx) { + if (ly > 0) { + return {true, NavigationDirection::kUp, DirectionalSource::kStick}; + } + return {true, NavigationDirection::kDown, DirectionalSource::kStick}; + } + if (lx < 0) { + return {true, NavigationDirection::kLeft, DirectionalSource::kStick}; + } + return {true, NavigationDirection::kRight, DirectionalSource::kStick}; +} + +ControllerActionSet ControllerNavigationMapper::BuildEdgeActions( + const hid::X_INPUT_GAMEPAD& gamepad) const { + ControllerActionSet actions; + + const uint16_t buttons = gamepad.buttons; + const uint16_t pressed = buttons & ~prev_buttons_; + + actions.accept = (pressed & hid::X_INPUT_GAMEPAD_A) != 0; + actions.back = (pressed & hid::X_INPUT_GAMEPAD_B) != 0; + actions.context = (pressed & hid::X_INPUT_GAMEPAD_X) != 0; + actions.quick_action = (pressed & hid::X_INPUT_GAMEPAD_Y) != 0; + actions.guide = (pressed & hid::X_INPUT_GAMEPAD_GUIDE) != 0; + actions.section_prev = (pressed & hid::X_INPUT_GAMEPAD_LEFT_SHOULDER) != 0; + actions.section_next = (pressed & hid::X_INPUT_GAMEPAD_RIGHT_SHOULDER) != 0; + + const bool left_trigger_pressed = + gamepad.left_trigger >= config_.trigger_threshold; + const bool right_trigger_pressed = + gamepad.right_trigger >= config_.trigger_threshold; + actions.page_prev = left_trigger_pressed && !prev_left_trigger_pressed_; + actions.page_next = right_trigger_pressed && !prev_right_trigger_pressed_; + + return actions; +} + +void ControllerNavigationMapper::UpdateDirectionalRepeat( + const ActiveDirection& direction, uint64_t now_ms, + ControllerActionSet& out_actions) { + if (!direction.active) { + repeat_active_ = false; + repeat_source_ = DirectionalSource::kNone; + return; + } + + const bool direction_changed = !repeat_active_ || + repeat_direction_ != direction.direction || + repeat_source_ != direction.source; + + if (direction_changed) { + out_actions.SetDirection(direction.direction); + repeat_active_ = true; + repeat_direction_ = direction.direction; + repeat_source_ = direction.source; + next_repeat_ms_ = now_ms + config_.repeat_initial_delay_ms; + } else if (now_ms >= next_repeat_ms_) { + out_actions.SetDirection(direction.direction); + const uint32_t interval = std::max(config_.repeat_interval_ms, 1u); + do { + next_repeat_ms_ += interval; + } while (next_repeat_ms_ <= now_ms); + } +} + +void FocusGraph::Clear() { + nodes_.clear(); + current_ = kInvalidFocusNodeId; +} + +void FocusGraph::AddOrUpdateNode(const FocusNode& node) { + if (node.id == kInvalidFocusNodeId) { + return; + } + nodes_[node.id] = node; + if (current_ == kInvalidFocusNodeId && node.enabled) { + current_ = node.id; + } +} + +bool FocusGraph::HasNode(FocusNodeId id) const { + return nodes_.find(id) != nodes_.end(); +} + +void FocusGraph::SetNodeEnabled(FocusNodeId id, bool enabled) { + auto it = nodes_.find(id); + if (it == nodes_.end()) { + return; + } + it->second.enabled = enabled; + if (id == current_ && !enabled) { + current_ = FirstEnabledNode(); + } +} + +bool FocusGraph::SetCurrent(FocusNodeId id) { + auto it = nodes_.find(id); + if (it == nodes_.end() || !it->second.enabled) { + return false; + } + current_ = id; + return true; +} + +FocusNodeId FocusGraph::Move(NavigationDirection direction) { + if (current_ == kInvalidFocusNodeId) { + current_ = FirstEnabledNode(); + return current_; + } + + auto it = nodes_.find(current_); + if (it == nodes_.end() || !it->second.enabled) { + current_ = FirstEnabledNode(); + return current_; + } + + FocusNodeId candidate = ResolveEdge(it->second, direction); + if (candidate == kInvalidFocusNodeId) { + return current_; + } + auto next_it = nodes_.find(candidate); + if (next_it == nodes_.end() || !next_it->second.enabled) { + return current_; + } + current_ = candidate; + return current_; +} + +FocusNodeId FocusGraph::FirstEnabledNode() const { + FocusNodeId first_enabled = kInvalidFocusNodeId; + for (const auto& [id, node] : nodes_) { + if (!node.enabled) { + continue; + } + if (first_enabled == kInvalidFocusNodeId || id < first_enabled) { + first_enabled = id; + } + } + return first_enabled; +} + +FocusNodeId FocusGraph::ResolveEdge(const FocusNode& node, + NavigationDirection direction) const { + switch (direction) { + case NavigationDirection::kUp: + return node.up; + case NavigationDirection::kDown: + return node.down; + case NavigationDirection::kLeft: + return node.left; + case NavigationDirection::kRight: + return node.right; + } + return kInvalidFocusNodeId; +} + +} // namespace apple +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/shared/apple_ui_navigation.h b/src/xenia/ui/ios/shared/apple_ui_navigation.h new file mode 100644 index 000000000..f644666e1 --- /dev/null +++ b/src/xenia/ui/ios/shared/apple_ui_navigation.h @@ -0,0 +1,139 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_APPLE_UI_NAVIGATION_H_ +#define XENIA_UI_APPLE_UI_NAVIGATION_H_ + +#include +#include + +#include "xenia/hid/input.h" + +namespace xe { +namespace ui { +namespace apple { + +enum class NavigationDirection : uint8_t { + kUp = 0, + kDown = 1, + kLeft = 2, + kRight = 3, +}; + +struct ControllerActionSet { + bool navigate_up = false; + bool navigate_down = false; + bool navigate_left = false; + bool navigate_right = false; + + bool accept = false; // A + bool back = false; // B + bool context = false; // X + bool quick_action = false; // Y + bool guide = false; // Guide + + bool section_prev = false; // LB + bool section_next = false; // RB + bool page_prev = false; // LT + bool page_next = false; // RT + + bool Any() const; + void SetDirection(NavigationDirection direction); +}; + +struct ControllerNavigationConfig { + int16_t stick_deadzone = 12000; + uint8_t trigger_threshold = 32; + uint32_t repeat_initial_delay_ms = 300; + uint32_t repeat_interval_ms = 75; +}; + +class ControllerNavigationMapper { + public: + explicit ControllerNavigationMapper( + ControllerNavigationConfig config = ControllerNavigationConfig()); + + // `now_ms` should be monotonic time in milliseconds. + ControllerActionSet Update(const hid::X_INPUT_STATE& state, uint64_t now_ms); + void Reset(); + + private: + enum class DirectionalSource : uint8_t { + kNone = 0, + kDpad = 1, + kStick = 2, + }; + + struct ActiveDirection { + bool active = false; + NavigationDirection direction = NavigationDirection::kUp; + DirectionalSource source = DirectionalSource::kNone; + }; + + static ActiveDirection ResolveDirection(const hid::X_INPUT_GAMEPAD& gamepad, + int16_t stick_deadzone); + + ControllerActionSet BuildEdgeActions( + const hid::X_INPUT_GAMEPAD& gamepad) const; + void UpdateDirectionalRepeat(const ActiveDirection& direction, + uint64_t now_ms, + ControllerActionSet& out_actions); + + ControllerNavigationConfig config_; + + uint16_t prev_buttons_ = 0; + bool prev_left_trigger_pressed_ = false; + bool prev_right_trigger_pressed_ = false; + + bool repeat_active_ = false; + NavigationDirection repeat_direction_ = NavigationDirection::kUp; + DirectionalSource repeat_source_ = DirectionalSource::kNone; + uint64_t next_repeat_ms_ = 0; +}; + +using FocusNodeId = uint32_t; +constexpr FocusNodeId kInvalidFocusNodeId = 0; + +struct FocusNode { + FocusNodeId id = kInvalidFocusNodeId; + FocusNodeId up = kInvalidFocusNodeId; + FocusNodeId down = kInvalidFocusNodeId; + FocusNodeId left = kInvalidFocusNodeId; + FocusNodeId right = kInvalidFocusNodeId; + bool enabled = true; +}; + +class FocusGraph { + public: + void Clear(); + void AddOrUpdateNode(const FocusNode& node); + bool HasNode(FocusNodeId id) const; + + void SetNodeEnabled(FocusNodeId id, bool enabled); + + FocusNodeId current() const { return current_; } + bool SetCurrent(FocusNodeId id); + + // Returns the resulting focused node ID (or current if movement fails). + FocusNodeId Move(NavigationDirection direction); + + private: + FocusNodeId FirstEnabledNode() const; + FocusNodeId ResolveEdge(const FocusNode& node, + NavigationDirection direction) const; + + std::unordered_map nodes_; + FocusNodeId current_ = kInvalidFocusNodeId; +}; + +} // namespace apple +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_APPLE_UI_NAVIGATION_H_ diff --git a/src/xenia/ui/ios/shared/ios_hero_glow_palette.h b/src/xenia/ui/ios/shared/ios_hero_glow_palette.h new file mode 100644 index 000000000..017b29ded --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_hero_glow_palette.h @@ -0,0 +1,28 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_HERO_GLOW_PALETTE_H_ +#define XENIA_UI_IOS_HERO_GLOW_PALETTE_H_ + +#import + +// Hero artwork -> glow color extraction (used by the compatibility hero card). +typedef struct { + UIColor* primary; + UIColor* secondary; +} XEHeroGlowPalette; + +// Color math shared by hero/detail UI modules. +BOOL xe_color_to_rgb_components(UIColor* color, CGFloat* r, CGFloat* g, CGFloat* b, CGFloat* a); +UIColor* xe_blend_rgb_colors(UIColor* a, UIColor* b, CGFloat amount); +CGFloat xe_color_luma(UIColor* color); + +XEHeroGlowPalette xe_extract_hero_glow_palette(UIImage* image); + +#endif // XENIA_UI_IOS_HERO_GLOW_PALETTE_H_ diff --git a/src/xenia/ui/ios/shared/ios_hero_glow_palette.mm b/src/xenia/ui/ios/shared/ios_hero_glow_palette.mm new file mode 100644 index 000000000..265c0329d --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_hero_glow_palette.mm @@ -0,0 +1,347 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_hero_glow_palette.h" + +#include +#include +#include + +#import "xenia/ui/ios/shared/ios_theme.h" + +BOOL xe_color_to_rgb_components(UIColor* color, CGFloat* r, CGFloat* g, CGFloat* b, CGFloat* a) { + if (!color || !r || !g || !b || !a) { + return NO; + } + if ([color getRed:r green:g blue:b alpha:a]) { + return YES; + } + CGFloat white = 0.0; + if ([color getWhite:&white alpha:a]) { + *r = white; + *g = white; + *b = white; + return YES; + } + return NO; +} + +UIColor* xe_blend_rgb_colors(UIColor* a, UIColor* b, CGFloat amount) { + if (!a) { + return b ?: [XeniaTheme accent]; + } + if (!b) { + return a; + } + CGFloat ra = 0.0, ga = 0.0, ba = 0.0, aa = 1.0; + CGFloat rb = 0.0, gb = 0.0, bb = 0.0, ab = 1.0; + if (!xe_color_to_rgb_components(a, &ra, &ga, &ba, &aa) || + !xe_color_to_rgb_components(b, &rb, &gb, &bb, &ab)) { + return a; + } + CGFloat t = MIN(1.0, MAX(0.0, amount)); + return [UIColor colorWithRed:ra + (rb - ra) * t + green:ga + (gb - ga) * t + blue:ba + (bb - ba) * t + alpha:aa + (ab - aa) * t]; +} + +CGFloat xe_color_luma(UIColor* color) { + CGFloat r = 0.0, g = 0.0, b = 0.0, a = 1.0; + if (!xe_color_to_rgb_components(color, &r, &g, &b, &a)) { + return 0.0; + } + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +namespace { + +CGFloat xe_wrap_unit(CGFloat value) { + CGFloat wrapped = fmod(value, 1.0); + if (wrapped < 0.0) { + wrapped += 1.0; + } + return wrapped; +} + +CGFloat xe_shortest_hue_delta(CGFloat from, CGFloat to) { + CGFloat delta = xe_wrap_unit(to) - xe_wrap_unit(from); + if (delta > 0.5) { + delta -= 1.0; + } + if (delta < -0.5) { + delta += 1.0; + } + return delta; +} + +double xe_hue_from_rgb(double r, double g, double b) { + double max_v = std::max({r, g, b}); + double min_v = std::min({r, g, b}); + double delta = max_v - min_v; + if (delta <= 1e-6) { + return 0.0; + } + double h = 0.0; + if (max_v == r) { + h = (g - b) / delta; + } else if (max_v == g) { + h = 2.0 + (b - r) / delta; + } else { + h = 4.0 + (r - g) / delta; + } + h /= 6.0; + if (h < 0.0) { + h += 1.0; + } + return h; +} + +UIColor* xe_color_from_weighted_rgb(double r, double g, double b, double weight) { + if (weight <= 0.0) { + return nil; + } + return [UIColor colorWithRed:(CGFloat)(r / weight) + green:(CGFloat)(g / weight) + blue:(CGFloat)(b / weight) + alpha:1.0]; +} + +UIColor* xe_adjusted_glow_color(UIColor* color, BOOL secondary) { + if (!color) { + return [XeniaTheme accent]; + } + CGFloat h = 0.0, s = 0.0, v = 0.0, a = 1.0; + if (![color getHue:&h saturation:&s brightness:&v alpha:&a]) { + CGFloat r = 0.0, g = 0.0, b = 0.0; + if (![color getRed:&r green:&g blue:&b alpha:&a]) { + return [XeniaTheme accent]; + } + color = [UIColor colorWithRed:r green:g blue:b alpha:1.0]; + if (![color getHue:&h saturation:&s brightness:&v alpha:&a]) { + return [XeniaTheme accent]; + } + } + + UIColor* mint = [XeniaTheme accent]; + CGFloat mh = 0.0, ms = 0.0, mv = 0.0, ma = 1.0; + [mint getHue:&mh saturation:&ms brightness:&mv alpha:&ma]; + (void)ms; + (void)mv; + (void)ma; + + if (s < 0.11) { + CGFloat hue = xe_wrap_unit(mh + xe_shortest_hue_delta(mh, h) * 0.22); + CGFloat sat = secondary ? 0.30 : 0.36; + CGFloat val = MIN(1.0, MAX(secondary ? 0.56 : 0.62, v * (secondary ? 1.20 : 1.28))); + return [UIColor colorWithHue:hue saturation:sat brightness:val alpha:1.0]; + } + + h = xe_wrap_unit(h); + s = MIN(0.98, MAX(secondary ? 0.40 : 0.48, s * (secondary ? 1.36 : 1.52))); + v = MIN(1.0, + MAX(secondary ? 0.58 : 0.64, v * (secondary ? 1.24 : 1.34) + (secondary ? 0.02 : 0.05))); + if (secondary) { + h = xe_wrap_unit(h + xe_shortest_hue_delta(h, mh) * 0.05); + } + return [UIColor colorWithHue:h saturation:s brightness:v alpha:1.0]; +} + +} // namespace + +XEHeroGlowPalette xe_extract_hero_glow_palette(UIImage* image) { + XEHeroGlowPalette palette; + palette.primary = [XeniaTheme accent]; + palette.secondary = [XeniaTheme accentHover]; + if (!image || !image.CGImage) { + return palette; + } + + static constexpr size_t kSampleW = 40; + static constexpr size_t kSampleH = 56; + static constexpr size_t kHueBins = 24; + static constexpr size_t kBytesPerPixel = 4; + static constexpr size_t kStride = kSampleW * kBytesPerPixel; + static constexpr double kTopIgnoreRatio = 0.20; + uint8_t pixels[kSampleH][kStride]; + memset(pixels, 0, sizeof(pixels)); + + CGColorSpaceRef color_space = CGColorSpaceCreateDeviceRGB(); + if (!color_space) { + return palette; + } + CGContextRef ctx = CGBitmapContextCreate( + pixels, kSampleW, kSampleH, 8, kStride, color_space, + kCGBitmapByteOrder32Big | static_cast(kCGImageAlphaPremultipliedLast)); + CGColorSpaceRelease(color_space); + if (!ctx) { + return palette; + } + CGContextSetInterpolationQuality(ctx, kCGInterpolationLow); + CGContextDrawImage(ctx, CGRectMake(0, 0, kSampleW, kSampleH), image.CGImage); + CGContextRelease(ctx); + + double hue_weight[kHueBins] = {}; + double hue_r[kHueBins] = {}; + double hue_g[kHueBins] = {}; + double hue_b[kHueBins] = {}; + double primary_hue_weight[kHueBins] = {}; + double primary_hue_r[kHueBins] = {}; + double primary_hue_g[kHueBins] = {}; + double primary_hue_b[kHueBins] = {}; + double accent_hue_weight[kHueBins] = {}; + double accent_hue_r[kHueBins] = {}; + double accent_hue_g[kHueBins] = {}; + double accent_hue_b[kHueBins] = {}; + double neutral_r = 0.0, neutral_g = 0.0, neutral_b = 0.0, neutral_w = 0.0; + double total_r = 0.0, total_g = 0.0, total_b = 0.0, total_w = 0.0; + + const size_t top_ignore_row = (size_t)floor((double)kSampleH * kTopIgnoreRatio); + const double usable_rows = (double)(kSampleH - top_ignore_row); + for (size_t y = top_ignore_row; y < kSampleH; ++y) { + double row_t = usable_rows > 0.0 ? ((double)(y - top_ignore_row) / usable_rows) : 0.0; + for (size_t x = 0; x < kSampleW; ++x) { + size_t idx = x * kBytesPerPixel; + double r = pixels[y][idx] / 255.0; + double g = pixels[y][idx + 1] / 255.0; + double b = pixels[y][idx + 2] / 255.0; + double a = pixels[y][idx + 3] / 255.0; + if (a < 0.10) { + continue; + } + + double max_v = std::max({r, g, b}); + double min_v = std::min({r, g, b}); + double delta = max_v - min_v; + double sat = max_v > 1e-6 ? (delta / max_v) : 0.0; + double luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + if (luma < 0.02) { + continue; + } + + double center_x = ((double)x + 0.5) / (double)kSampleW; + double center_bias = 0.70 + 0.30 * (1.0 - fabs(center_x - 0.5) * 2.0); + double vertical_bias = 0.86 + 0.14 * row_t; + double base_w = a * center_bias * vertical_bias; + double top_bias = 1.28 - row_t * 0.84; + double bottom_bias = 0.30 + row_t * 1.18; + + double total_weight = base_w * (0.12 + luma * 0.72 + sat * 0.64); + total_r += r * total_weight; + total_g += g * total_weight; + total_b += b * total_weight; + total_w += total_weight; + + if (sat >= 0.12) { + double hue = xe_hue_from_rgb(r, g, b); + size_t bin = (size_t)floor(hue * (double)kHueBins) % kHueBins; + double weight = base_w * (0.16 + sat * 2.05 + luma * 0.82); + hue_weight[bin] += weight; + hue_r[bin] += r * weight; + hue_g[bin] += g * weight; + hue_b[bin] += b * weight; + double primary_weight = weight * top_bias; + primary_hue_weight[bin] += primary_weight; + primary_hue_r[bin] += r * primary_weight; + primary_hue_g[bin] += g * primary_weight; + primary_hue_b[bin] += b * primary_weight; + double accent_weight = weight * bottom_bias; + accent_hue_weight[bin] += accent_weight; + accent_hue_r[bin] += r * accent_weight; + accent_hue_g[bin] += g * accent_weight; + accent_hue_b[bin] += b * accent_weight; + } else { + double neutral_weight = base_w * (0.14 + luma * 0.86); + neutral_r += r * neutral_weight; + neutral_g += g * neutral_weight; + neutral_b += b * neutral_weight; + neutral_w += neutral_weight; + } + } + } + + size_t primary_bin = 0; + for (size_t i = 1; i < kHueBins; ++i) { + if (primary_hue_weight[i] > primary_hue_weight[primary_bin]) { + primary_bin = i; + } + } + if (primary_hue_weight[primary_bin] <= 0.0) { + for (size_t i = 1; i < kHueBins; ++i) { + if (hue_weight[i] > hue_weight[primary_bin]) { + primary_bin = i; + } + } + } + + UIColor* primary = nil; + UIColor* secondary = nil; + if (primary_hue_weight[primary_bin] > 0.0) { + primary = + xe_color_from_weighted_rgb(primary_hue_r[primary_bin], primary_hue_g[primary_bin], + primary_hue_b[primary_bin], primary_hue_weight[primary_bin]); + } else if (hue_weight[primary_bin] > 0.0) { + primary = xe_color_from_weighted_rgb(hue_r[primary_bin], hue_g[primary_bin], hue_b[primary_bin], + hue_weight[primary_bin]); + } + if (!primary) { + primary = xe_color_from_weighted_rgb(total_r, total_g, total_b, total_w); + } + + if (accent_hue_weight[primary_bin] > 0.0 || hue_weight[primary_bin] > 0.0) { + size_t secondary_bin = kHueBins; + for (size_t i = 0; i < kHueBins; ++i) { + if (i == primary_bin) { + continue; + } + size_t distance = (i > primary_bin) ? (i - primary_bin) : (primary_bin - i); + distance = std::min(distance, kHueBins - distance); + if (distance < 2) { + continue; + } + if (accent_hue_weight[i] < accent_hue_weight[primary_bin] * 0.24 && + hue_weight[i] < hue_weight[primary_bin] * 0.24) { + continue; + } + double i_weight = accent_hue_weight[i] > 0.0 ? accent_hue_weight[i] : hue_weight[i]; + double best_weight = (secondary_bin == kHueBins) ? -1.0 + : (accent_hue_weight[secondary_bin] > 0.0 + ? accent_hue_weight[secondary_bin] + : hue_weight[secondary_bin]); + if (i_weight > best_weight) { + secondary_bin = i; + } + } + if (secondary_bin != kHueBins) { + if (accent_hue_weight[secondary_bin] > 0.0) { + secondary = xe_color_from_weighted_rgb( + accent_hue_r[secondary_bin], accent_hue_g[secondary_bin], accent_hue_b[secondary_bin], + accent_hue_weight[secondary_bin]); + } else { + secondary = xe_color_from_weighted_rgb(hue_r[secondary_bin], hue_g[secondary_bin], + hue_b[secondary_bin], hue_weight[secondary_bin]); + } + } + } + + if (!secondary && neutral_w > 0.0) { + secondary = xe_color_from_weighted_rgb(neutral_r, neutral_g, neutral_b, neutral_w); + } + if (!secondary) { + secondary = primary ? xe_blend_rgb_colors(primary, [XeniaTheme accentHover], 0.28) + : [XeniaTheme accentHover]; + } + if (!primary) { + primary = [XeniaTheme accent]; + } + + palette.primary = xe_adjusted_glow_color(primary, NO); + palette.secondary = xe_adjusted_glow_color(secondary, YES); + return palette; +} diff --git a/src/xenia/ui/ios/shared/ios_status_toast.h b/src/xenia/ui/ios/shared/ios_status_toast.h new file mode 100644 index 000000000..330a19864 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_status_toast.h @@ -0,0 +1,31 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_SHARED_IOS_STATUS_TOAST_H_ +#define XENIA_UI_IOS_SHARED_IOS_STATUS_TOAST_H_ + +#import + +typedef NS_ENUM(NSInteger, XeniaIOSStatusToastStyle) { + XeniaIOSStatusToastStyleInfo = 0, + XeniaIOSStatusToastStyleSuccess, + XeniaIOSStatusToastStyleWarning, + XeniaIOSStatusToastStyleError, +}; + +@interface XeniaIOSStatusToastPresenter : NSObject + +- (void)presentMessage:(NSString*)message + style:(XeniaIOSStatusToastStyle)style + inView:(UIView*)view; +- (void)dismiss; + +@end + +#endif // XENIA_UI_IOS_SHARED_IOS_STATUS_TOAST_H_ diff --git a/src/xenia/ui/ios/shared/ios_status_toast.mm b/src/xenia/ui/ios/shared/ios_status_toast.mm new file mode 100644 index 000000000..d2a548158 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_status_toast.mm @@ -0,0 +1,170 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_status_toast.h" + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +UIColor* AccentForStyle(XeniaIOSStatusToastStyle style) { + switch (style) { + case XeniaIOSStatusToastStyleSuccess: + return [XeniaTheme accent]; + case XeniaIOSStatusToastStyleWarning: + return [XeniaTheme statusWarning]; + case XeniaIOSStatusToastStyleError: + return [XeniaTheme statusError]; + case XeniaIOSStatusToastStyleInfo: + default: + return [XeniaTheme textSecondary]; + } +} + +NSString* SymbolForStyle(XeniaIOSStatusToastStyle style) { + switch (style) { + case XeniaIOSStatusToastStyleSuccess: + return @"checkmark.circle.fill"; + case XeniaIOSStatusToastStyleWarning: + return @"exclamationmark.triangle.fill"; + case XeniaIOSStatusToastStyleError: + return @"xmark.octagon.fill"; + case XeniaIOSStatusToastStyleInfo: + default: + return @"info.circle.fill"; + } +} + +} // namespace + +@implementation XeniaIOSStatusToastPresenter { + UIView* toast_; + NSTimer* timer_; +} + +- (void)dealloc { + [self dismiss]; + [super dealloc]; +} + +- (void)dismiss { + [timer_ invalidate]; + [timer_ release]; + timer_ = nil; + [toast_.layer removeAllAnimations]; + [toast_ removeFromSuperview]; + [toast_ release]; + toast_ = nil; +} + +- (void)timerFired:(NSTimer*)timer { + if (timer != timer_) { + return; + } + [timer_ release]; + timer_ = nil; + UIView* toast = [toast_ retain]; + [UIView animateWithDuration:UIAccessibilityIsReduceMotionEnabled() ? 0.12 : 0.24 + delay:0 + options:UIViewAnimationOptionCurveEaseIn | UIViewAnimationOptionAllowUserInteraction + animations:^{ + toast.alpha = 0.0; + toast.transform = UIAccessibilityIsReduceMotionEnabled() + ? CGAffineTransformIdentity + : CGAffineTransformMakeTranslation(0.0, -10.0); + } + completion:^(__unused BOOL finished) { + if (toast == self->toast_) { + [self dismiss]; + } + [toast release]; + }]; +} + +- (void)presentMessage:(NSString*)message + style:(XeniaIOSStatusToastStyle)style + inView:(UIView*)view { + if (!view || message.length == 0) { + return; + } + [self dismiss]; + + UIView* toast = [[[UIView alloc] init] autorelease]; + toast.translatesAutoresizingMaskIntoConstraints = NO; + toast.userInteractionEnabled = NO; + // Glass on iOS 26+, SystemMaterial on iOS 18-25. The card chrome sets + // backgroundColor=clear, cornerRadius=XeniaRadiusLg, the hairline border, + // and inserts the visual-effect backdrop at index 0 — content subviews + // (icon + label row) stack above it. + xe_apply_glass_card_chrome(toast, NO); + xe_apply_shadow_token(toast, XeniaShadowElevationMedium); + toast.alpha = 0.0; + toast.transform = UIAccessibilityIsReduceMotionEnabled() + ? CGAffineTransformIdentity + : CGAffineTransformMakeTranslation(0, -12.0); + + UIImageView* icon = [[[UIImageView alloc] + initWithImage:[UIImage systemImageNamed:SymbolForStyle(style)]] autorelease]; + icon.translatesAutoresizingMaskIntoConstraints = NO; + icon.tintColor = AccentForStyle(style); + + UILabel* label = [[[UILabel alloc] init] autorelease]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.text = message; + label.textColor = [XeniaTheme textPrimary]; + label.numberOfLines = 2; + xe_apply_label_font(label, UIFontTextStyleFootnote, 13.0, UIFontWeightSemibold); + + UIStackView* row = [[[UIStackView alloc] initWithArrangedSubviews:@[ icon, label ]] autorelease]; + row.translatesAutoresizingMaskIntoConstraints = NO; + row.axis = UILayoutConstraintAxisHorizontal; + row.alignment = UIStackViewAlignmentCenter; + row.spacing = 10.0; + row.layoutMargins = UIEdgeInsetsMake(10, 12, 10, 14); + row.layoutMarginsRelativeArrangement = YES; + [toast addSubview:row]; + [view addSubview:toast]; + + UILayoutGuide* safe = view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [toast.topAnchor constraintEqualToAnchor:safe.topAnchor constant:14], + [toast.centerXAnchor constraintEqualToAnchor:safe.centerXAnchor], + [toast.widthAnchor constraintLessThanOrEqualToAnchor:safe.widthAnchor constant:-32], + [toast.widthAnchor constraintGreaterThanOrEqualToConstant:220], + [row.topAnchor constraintEqualToAnchor:toast.topAnchor], + [row.leadingAnchor constraintEqualToAnchor:toast.leadingAnchor], + [row.trailingAnchor constraintEqualToAnchor:toast.trailingAnchor], + [row.bottomAnchor constraintEqualToAnchor:toast.bottomAnchor], + [icon.widthAnchor constraintEqualToConstant:20], + [icon.heightAnchor constraintEqualToConstant:20], + ]]; + + XEApplyAccessibility(toast, message, nil, nil, UIAccessibilityTraitStaticText); + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, message); + + toast_ = [toast retain]; + UINotificationFeedbackGenerator* feedback = + [[[UINotificationFeedbackGenerator alloc] init] autorelease]; + [feedback notificationOccurred:style == XeniaIOSStatusToastStyleError + ? UINotificationFeedbackTypeError + : UINotificationFeedbackTypeWarning]; + [UIView animateWithDuration:UIAccessibilityIsReduceMotionEnabled() ? 0.12 : 0.22 + animations:^{ + toast.alpha = 1.0; + toast.transform = CGAffineTransformIdentity; + }]; + timer_ = [[NSTimer scheduledTimerWithTimeInterval:3.4 + target:self + selector:@selector(timerFired:) + userInfo:nil + repeats:NO] retain]; +} + +@end diff --git a/src/xenia/ui/ios/shared/ios_system_utils.h b/src/xenia/ui/ios/shared/ios_system_utils.h new file mode 100644 index 000000000..43c563fc3 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_system_utils.h @@ -0,0 +1,61 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_SYSTEM_UTILS_H_ +#define XENIA_UI_IOS_SYSTEM_UTILS_H_ + +#import + +#include +#include + +// System / runtime utilities used across the iOS UI: code-signing & +// debugger-broker readiness, JIT availability, orientation control, app +// directories and device hardware identification. + +// JIT / W^X readiness. +BOOL xe_is_cs_debugged(void); +BOOL xe_can_mmap_exec_page(void); +BOOL xe_check_jit_available(void); +uint32_t xe_ios_code_sign_flags(void); + +// iOS major-version reporting (returns -1 if unavailable, e.g. tvOS). +int xe_ios_product_major_version(void); +BOOL xe_ios_requires_debugger_broker(void); + +// User-visible JIT status copy. +NSString* xe_jit_waiting_status_message(void); +NSString* xe_jit_not_detected_guidance_message(void); + +// Decorates a CALayer with a pulsing radial ring for the JIT status indicator. +void xe_add_jit_ring_pulse(CALayer* layer, NSString* key, CGFloat end_scale, CGFloat peak_opacity, + CFTimeInterval duration); + +// File system locations. +std::filesystem::path xe_get_ios_documents_path(void); + +// Orientation helpers. +void xe_request_orientation(UIViewController* view_controller, UIInterfaceOrientationMask mask, + UIInterfaceOrientation orientation); +void xe_request_landscape_orientation(UIViewController* view_controller); +void xe_request_current_orientation(UIViewController* view_controller); +UIInterfaceOrientation xe_interface_orientation_from_device_orientation( + UIDeviceOrientation orientation); +UIInterfaceOrientationMask xe_interface_orientation_mask(UIInterfaceOrientation orientation); +UIInterfaceOrientation xe_current_interface_orientation(UIView* view); + +// URL context helpers. +NSURL* xe_first_open_url_context_url(NSSet* url_contexts); + +// Device hardware identification. +NSString* xe_device_machine(void); +NSString* xe_device_display_name_for_machine(NSString* raw_machine); +NSString* xe_device_display_name(void); + +#endif // XENIA_UI_IOS_SYSTEM_UTILS_H_ diff --git a/src/xenia/ui/ios/shared/ios_system_utils.mm b/src/xenia/ui/ios/shared/ios_system_utils.mm new file mode 100644 index 000000000..646eccf47 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_system_utils.mm @@ -0,0 +1,365 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_system_utils.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "xenia/base/logging.h" + +extern "C" int csops(pid_t pid, unsigned int ops, void* useraddr, size_t usersize); + +#ifndef CS_OPS_STATUS +#define CS_OPS_STATUS 0 +#endif +#ifndef CS_DEBUGGED +#define CS_DEBUGGED 0x10000000 +#endif + +BOOL xe_is_cs_debugged(void) { + int flags = 0; + return !csops(getpid(), CS_OPS_STATUS, &flags, sizeof(flags)) && (flags & CS_DEBUGGED); +} + +BOOL xe_can_mmap_exec_page(void) { + long page_size = sysconf(_SC_PAGESIZE); + if (page_size <= 0) { + page_size = 16384; + } + void* test = + mmap(NULL, (size_t)page_size, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (test == MAP_FAILED) { + return NO; + } + munmap(test, (size_t)page_size); + return YES; +} + +int xe_ios_product_major_version(void) { +#if TARGET_OS_TV + return -1; +#else + size_t version_size = 0; + if (sysctlbyname("kern.osproductversion", nullptr, &version_size, nullptr, 0) == 0 && + version_size > 0) { + std::string version(version_size, '\0'); + if (sysctlbyname("kern.osproductversion", version.data(), &version_size, nullptr, 0) == 0 && + version_size > 0) { + if (!version.empty() && version.back() == '\0') { + version.pop_back(); + } + int parsed_major = 0; + size_t index = 0; + while (index < version.size() && version[index] >= '0' && version[index] <= '9') { + parsed_major = parsed_major * 10 + (version[index] - '0'); + ++index; + } + if (parsed_major > 0) { + return parsed_major; + } + } + } + return -1; +#endif +} + +BOOL xe_ios_requires_debugger_broker(void) { + const int major = xe_ios_product_major_version(); + return major >= 26; +} + +NSString* xe_jit_waiting_status_message(void) { + return @"JIT is not active. In StikDebug, assign Amethyst-MeloNX.js or universal.js."; +} + +NSString* xe_jit_not_detected_guidance_message(void) { + return @"JIT is not active. In StikDebug, assign Amethyst-MeloNX.js or universal.js."; +} + +void xe_add_jit_ring_pulse(CALayer* layer, NSString* key, CGFloat end_scale, CGFloat peak_opacity, + CFTimeInterval duration) { + if ([layer animationForKey:key]) { + return; + } + + CAMediaTimingFunction* ease_out = + [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; + + CAKeyframeAnimation* scale = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"]; + scale.values = @[ @1.0, @(1.0 + (end_scale - 1.0) * 0.45), @(end_scale) ]; + scale.keyTimes = @[ @0.0, @0.42, @1.0 ]; + scale.timingFunctions = @[ ease_out, ease_out ]; + scale.calculationMode = kCAAnimationCubic; + + CAKeyframeAnimation* fade = [CAKeyframeAnimation animationWithKeyPath:@"opacity"]; + fade.values = @[ @0.0, @(peak_opacity), @(peak_opacity * 0.55), @0.0 ]; + fade.keyTimes = @[ @0.0, @0.2, @0.58, @1.0 ]; + fade.timingFunctions = @[ ease_out, ease_out, ease_out ]; + fade.calculationMode = kCAAnimationLinear; + + CAAnimationGroup* group = [CAAnimationGroup animation]; + group.animations = @[ scale, fade ]; + group.duration = duration; + group.repeatCount = HUGE_VALF; + group.removedOnCompletion = NO; + group.fillMode = kCAFillModeBoth; + [layer addAnimation:group forKey:key]; +} + +// A plain RX mmap probe can succeed on iOS 18.5 while guest JIT execution is +// still unavailable. Treat debugger/JIT-enabled process state as part of the +// runtime readiness check so the launcher and automation don't produce false +// positives. +BOOL xe_check_jit_available(void) { return xe_is_cs_debugged() && xe_can_mmap_exec_page(); } + +uint32_t xe_ios_code_sign_flags(void) { + int flags = 0; + return !csops(getpid(), CS_OPS_STATUS, &flags, sizeof(flags)) ? static_cast(flags) : 0u; +} + +std::filesystem::path xe_get_ios_documents_path(void) { + @autoreleasepool { + NSArray* paths = + NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + if (paths.count > 0) { + return std::filesystem::path([paths[0] UTF8String]); + } + return std::filesystem::path([NSHomeDirectory() UTF8String]) / "Documents"; + } +} + +void xe_request_orientation(UIViewController* view_controller, UIInterfaceOrientationMask mask, + UIInterfaceOrientation orientation) { + if (!view_controller) { + return; + } + (void)orientation; +#if !TARGET_OS_TV + [view_controller setNeedsUpdateOfSupportedInterfaceOrientations]; + if (@available(iOS 16.0, *)) { + UIWindowScene* scene = view_controller.view.window.windowScene; + if (!scene) { + for (UIScene* connected_scene in [UIApplication sharedApplication].connectedScenes) { + if ([connected_scene isKindOfClass:[UIWindowScene class]]) { + scene = (UIWindowScene*)connected_scene; + break; + } + } + } + if (scene) { + UIWindowSceneGeometryPreferencesIOS* preferences = + [[UIWindowSceneGeometryPreferencesIOS alloc] initWithInterfaceOrientations:mask]; + [scene requestGeometryUpdateWithPreferences:preferences + errorHandler:^(NSError* error) { + XELOGW("iOS: Orientation geometry update failed: {}", + [[error localizedDescription] UTF8String]); + }]; + } + } + [UIViewController attemptRotationToDeviceOrientation]; +#endif +} + +void xe_request_landscape_orientation(UIViewController* view_controller) { + xe_request_orientation(view_controller, UIInterfaceOrientationMaskLandscape, + UIInterfaceOrientationLandscapeRight); +} + +UIInterfaceOrientation xe_interface_orientation_from_device_orientation( + UIDeviceOrientation orientation) { + switch (orientation) { + case UIDeviceOrientationLandscapeLeft: + return UIInterfaceOrientationLandscapeRight; + case UIDeviceOrientationLandscapeRight: + return UIInterfaceOrientationLandscapeLeft; + case UIDeviceOrientationPortrait: + return UIInterfaceOrientationPortrait; + case UIDeviceOrientationPortraitUpsideDown: + return UIInterfaceOrientationPortraitUpsideDown; + default: + return UIInterfaceOrientationUnknown; + } +} + +UIInterfaceOrientationMask xe_interface_orientation_mask(UIInterfaceOrientation orientation) { + switch (orientation) { + case UIInterfaceOrientationLandscapeLeft: + case UIInterfaceOrientationLandscapeRight: + return UIInterfaceOrientationMaskLandscape; + case UIInterfaceOrientationPortraitUpsideDown: + return UIInterfaceOrientationMaskPortraitUpsideDown; + case UIInterfaceOrientationPortrait: + default: + return UIInterfaceOrientationMaskPortrait; + } +} + +UIInterfaceOrientation xe_current_interface_orientation(UIView* view) { +#if !TARGET_OS_TV + UIWindowScene* scene = view.window.windowScene; + if (!scene) { + for (UIScene* connected_scene in [UIApplication sharedApplication].connectedScenes) { + if ([connected_scene isKindOfClass:[UIWindowScene class]]) { + scene = (UIWindowScene*)connected_scene; + break; + } + } + } + if (scene && scene.interfaceOrientation != UIInterfaceOrientationUnknown) { + return scene.interfaceOrientation; + } +#endif + UIInterfaceOrientation device_orientation = + xe_interface_orientation_from_device_orientation([UIDevice currentDevice].orientation); + if (device_orientation != UIInterfaceOrientationUnknown) { + return device_orientation; + } + return UIInterfaceOrientationPortrait; +} + +void xe_request_current_orientation(UIViewController* view_controller) { + if (!view_controller) { + return; + } + UIInterfaceOrientation orientation = UIInterfaceOrientationUnknown; +#if !TARGET_OS_TV + UIWindowScene* scene = view_controller.view.window.windowScene; + if (scene && scene.interfaceOrientation != UIInterfaceOrientationUnknown) { + orientation = scene.interfaceOrientation; + } +#endif + if (orientation == UIInterfaceOrientationUnknown) { + orientation = + xe_interface_orientation_from_device_orientation([UIDevice currentDevice].orientation); + } + if (orientation == UIInterfaceOrientationUnknown) { + [view_controller setNeedsUpdateOfSupportedInterfaceOrientations]; + [UIViewController attemptRotationToDeviceOrientation]; + return; + } + xe_request_orientation(view_controller, xe_interface_orientation_mask(orientation), orientation); +} + +NSURL* xe_first_open_url_context_url(NSSet* url_contexts) { + if (!url_contexts || url_contexts.count == 0) { + return nil; + } + UIOpenURLContext* context = [url_contexts anyObject]; + return context.URL; +} + +NSString* xe_device_machine(void) { + size_t size = 0; + sysctlbyname("hw.machine", nullptr, &size, nullptr, 0); + if (size == 0) { + return @"Unknown"; + } + + char* machine = static_cast(malloc(size)); + if (!machine) { + return @"Unknown"; + } + sysctlbyname("hw.machine", machine, &size, nullptr, 0); + NSString* value = [NSString stringWithCString:machine encoding:NSUTF8StringEncoding]; + free(machine); + return value ?: @"Unknown"; +} + +NSString* xe_device_display_name_for_machine(NSString* raw_machine) { + if (![raw_machine isKindOfClass:[NSString class]]) { + return @"Unknown"; + } + NSString* machine = [raw_machine + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (machine.length == 0) { + return @"Unknown"; + } + static NSDictionary* overrides = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSMutableDictionary* map = [NSMutableDictionary dictionary]; + void (^add_names)(NSArray*, NSString*) = + ^(NSArray* codes, NSString* name) { + for (NSString* code in codes) { + map[code] = name; + } + }; + + add_names(@[ @"iPhone13,1" ], @"iPhone 12 Mini"); + add_names(@[ @"iPhone13,2" ], @"iPhone 12"); + add_names(@[ @"iPhone13,3" ], @"iPhone 12 Pro"); + add_names(@[ @"iPhone13,4" ], @"iPhone 12 Pro Max"); + add_names(@[ @"iPhone14,2" ], @"iPhone 13 Pro"); + add_names(@[ @"iPhone14,3" ], @"iPhone 13 Pro Max"); + add_names(@[ @"iPhone14,4" ], @"iPhone 13 Mini"); + add_names(@[ @"iPhone14,5" ], @"iPhone 13"); + add_names(@[ @"iPhone14,6" ], @"iPhone SE 3rd Gen"); + add_names(@[ @"iPhone14,7" ], @"iPhone 14"); + add_names(@[ @"iPhone14,8" ], @"iPhone 14 Plus"); + add_names(@[ @"iPhone15,2" ], @"iPhone 14 Pro"); + add_names(@[ @"iPhone15,3" ], @"iPhone 14 Pro Max"); + add_names(@[ @"iPhone15,4" ], @"iPhone 15"); + add_names(@[ @"iPhone15,5" ], @"iPhone 15 Plus"); + add_names(@[ @"iPhone16,1" ], @"iPhone 15 Pro"); + add_names(@[ @"iPhone16,2" ], @"iPhone 15 Pro Max"); + add_names(@[ @"iPhone17,1" ], @"iPhone 16 Pro"); + add_names(@[ @"iPhone17,2" ], @"iPhone 16 Pro Max"); + add_names(@[ @"iPhone17,3" ], @"iPhone 16"); + add_names(@[ @"iPhone17,4" ], @"iPhone 16 Plus"); + add_names(@[ @"iPhone17,5" ], @"iPhone 16e"); + add_names(@[ @"iPhone18,1" ], @"iPhone 17 Pro"); + add_names(@[ @"iPhone18,2" ], @"iPhone 17 Pro Max"); + add_names(@[ @"iPhone18,3" ], @"iPhone 17"); + add_names(@[ @"iPhone18,4" ], @"iPhone Air"); + + // Collapse WiFi and cellular iPad SKUs to a single product label. + add_names(@[ @"iPad8,9", @"iPad8,10" ], @"iPad Pro 11 inch 4th Gen"); + add_names(@[ @"iPad8,11", @"iPad8,12" ], @"iPad Pro 12.9 inch 4th Gen"); + add_names(@[ @"iPad11,1", @"iPad11,2" ], @"iPad mini 5th Gen"); + add_names(@[ @"iPad11,3", @"iPad11,4" ], @"iPad Air 3rd Gen"); + add_names(@[ @"iPad11,6", @"iPad11,7" ], @"iPad 8th Gen"); + add_names(@[ @"iPad12,1", @"iPad12,2" ], @"iPad 9th Gen"); + add_names(@[ @"iPad13,1", @"iPad13,2" ], @"iPad Air 4th Gen"); + add_names(@[ @"iPad13,4", @"iPad13,5", @"iPad13,6", @"iPad13,7" ], @"iPad Pro 11 inch 5th Gen"); + add_names(@[ @"iPad13,8", @"iPad13,9", @"iPad13,10", @"iPad13,11" ], + @"iPad Pro 12.9 inch 5th Gen"); + add_names(@[ @"iPad13,16", @"iPad13,17" ], @"iPad Air 5th Gen"); + add_names(@[ @"iPad13,18", @"iPad13,19" ], @"iPad 10th Gen"); + add_names(@[ @"iPad14,1", @"iPad14,2" ], @"iPad mini 6th Gen"); + add_names(@[ @"iPad14,3", @"iPad14,4" ], @"iPad Pro 11 inch 4th Gen"); + add_names(@[ @"iPad14,5", @"iPad14,6" ], @"iPad Pro 12.9 inch 6th Gen"); + add_names(@[ @"iPad14,8", @"iPad14,9" ], @"iPad Air 11 inch 6th Gen"); + add_names(@[ @"iPad14,10", @"iPad14,11" ], @"iPad Air 13 inch 6th Gen"); + add_names(@[ @"iPad15,3", @"iPad15,4" ], @"iPad Air 11-inch 7th Gen"); + add_names(@[ @"iPad15,5", @"iPad15,6" ], @"iPad Air 13-inch 7th Gen"); + add_names(@[ @"iPad15,7", @"iPad15,8" ], @"iPad 11th Gen"); + add_names(@[ @"iPad16,1", @"iPad16,2" ], @"iPad mini 7th Gen"); + add_names(@[ @"iPad16,3", @"iPad16,4" ], @"iPad Pro 11 inch 5th Gen"); + add_names(@[ @"iPad16,5", @"iPad16,6" ], @"iPad Pro 12.9 inch 7th Gen"); + + overrides = [map copy]; + }); + + NSString* display_name = overrides[machine]; + if ([display_name isKindOfClass:[NSString class]] && display_name.length > 0) { + return display_name; + } + return machine; +} + +NSString* xe_device_display_name(void) { + return xe_device_display_name_for_machine(xe_device_machine()); +} diff --git a/src/xenia/ui/ios/shared/ios_theme.h b/src/xenia/ui/ios/shared/ios_theme.h new file mode 100644 index 000000000..a80019907 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_theme.h @@ -0,0 +1,139 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_THEME_H_ +#define XENIA_UI_IOS_THEME_H_ + +#import + +// Centralized iOS theme primitives. These mirror the Xenia website tokens +// (see assets/website / apple_theme_tokens) so the UIKit and web surfaces +// stay visually aligned. Keep additions here so view controllers don't grow +// their own private color/font palettes. + +// Border radii matching the website's Tailwind scale. XeniaRadiusXs and +// XeniaRadiusXxl are Xenia-iOS additions for small chips and large floating +// surfaces (achievement banner). +static constexpr CGFloat XeniaRadiusXs = 4.0; +static constexpr CGFloat XeniaRadiusMd = 8.0; +static constexpr CGFloat XeniaRadiusLg = 12.0; +static constexpr CGFloat XeniaRadiusXl = 16.0; +static constexpr CGFloat XeniaRadiusXxl = 24.0; + +// Shadow elevation steps. Used with [XeniaTheme shadowColorForElevation:] and +// xe_apply_shadow_token() (see ios_view_helpers.h). +typedef NS_ENUM(NSInteger, XeniaShadowElevation) { + XeniaShadowElevationSubtle = 0, // floating window chrome + XeniaShadowElevationMedium = 1, // status toast / focus glow + XeniaShadowElevationElevated = 2, // achievement notification / sheet stack +}; + +// Geometry for the chosen elevation, in points. Trait-independent. +typedef struct { + CGFloat radius; + CGSize offset; +} XeniaShadowGeometry; + +XeniaShadowGeometry XeniaShadowGeometryForElevation(XeniaShadowElevation elevation); + +// One concrete typography preset — a point size + UIFontWeight + the +// UIFontTextStyle to scale against for Dynamic Type. Picked semantically: +// callers ask for `XeniaTextStyleHeadline()` instead of remembering 17/0.3/Body. +typedef struct { + CGFloat point_size; + UIFontWeight weight; + UIFontTextStyle text_style; +} XeniaTextStyle; + +XeniaTextStyle XeniaTextStyleCaption2(void); +XeniaTextStyle XeniaTextStyleCaption1(void); +XeniaTextStyle XeniaTextStyleFootnote(void); +XeniaTextStyle XeniaTextStyleSubheadline(void); +XeniaTextStyle XeniaTextStyleBody(void); +XeniaTextStyle XeniaTextStyleHeadline(void); +XeniaTextStyle XeniaTextStyleTitle3(void); +XeniaTextStyle XeniaTextStyleTitle2(void); + +@interface XeniaTheme : NSObject + +// Surfaces. ++ (UIColor*)bgPrimary; // #09090b ++ (UIColor*)bgSurface; // #18181b ++ (UIColor*)bgSurface2; // #27272a ++ (UIColor*)bgSurface3; // #3f3f46 + +// Text. ++ (UIColor*)textPrimary; // #fafafa ++ (UIColor*)textSecondary; // #a1a1aa ++ (UIColor*)textMuted; // #71717a + +// Accent. ++ (UIColor*)accent; // #34d399 ++ (UIColor*)accentHover; // #6ee7b7 ++ (UIColor*)accentFg; // #09090b + +// Status. ++ (UIColor*)statusError; // #f87171 ++ (UIColor*)statusWarning; // #fbbf24 + +// Strokes / overlays. ++ (UIColor*)border; // white 6% ++ (UIColor*)borderHover; // white 10% ++ (UIColor*)overlay; // black 85% ++ (UIColor*)overlayLight; // black 58% + +// Branded categorical icon palette for the settings hub and similar surfaces. +// Replaces the previous UIColor.system{Blue,Indigo,Orange,Purple,Red,Gray}Color +// calls so the icon row stays on-brand rather than OS-default. ++ (UIColor*)sectionIconBlue; ++ (UIColor*)sectionIconIndigo; ++ (UIColor*)sectionIconOrange; ++ (UIColor*)sectionIconPurple; ++ (UIColor*)sectionIconRed; ++ (UIColor*)sectionIconGray; + +// On-screen touch overlay tints — single source of truth for the seven colours +// that IOSTouchTintStyle enumerates. These render over the game view rather +// than over iOS chrome, so they aren't trait-aware. ++ (UIColor*)touchTintAmber; ++ (UIColor*)touchTintSky; ++ (UIColor*)touchTintMint; ++ (UIColor*)touchTintRose; ++ (UIColor*)touchTintLime; ++ (UIColor*)touchTintCoral; ++ (UIColor*)touchTintSlate; + +// Dynamic shadow color for the given elevation. Always black; the alpha +// resolves at the trait collection it's evaluated in (dark mode reads darker +// than light mode). Pair with shadowOpacity = 1.0 on the layer. ++ (UIColor*)shadowColorForElevation:(XeniaShadowElevation)elevation; + +// Spacing scale — replaces the 6/8/10/12/14/16/18/24/32 magic numbers seen +// throughout the iOS UI. Use the closest semantic step rather than picking +// an arbitrary number. ++ (CGFloat)spacingXs; ++ (CGFloat)spacingSm; ++ (CGFloat)spacingMd; ++ (CGFloat)spacingLg; ++ (CGFloat)spacingXl; ++ (CGFloat)spacingXxl; + +// Recurring opacity values for overlays, tints and chips. ++ (CGFloat)opacitySubtle; ++ (CGFloat)opacitySoft; ++ (CGFloat)opacityMedium; ++ (CGFloat)opacityStrong; ++ (CGFloat)opacityHeavy; + +@end + +#import "xenia/ui/ios/shared/ios_hero_glow_palette.h" +#import "xenia/ui/ios/shared/ios_theme_controls.h" + +#endif // XENIA_UI_IOS_THEME_H_ diff --git a/src/xenia/ui/ios/shared/ios_theme.mm b/src/xenia/ui/ios/shared/ios_theme.mm new file mode 100644 index 000000000..591f1e8bb --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_theme.mm @@ -0,0 +1,271 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_theme.h" + +#include "xenia/ui/ios/shared/apple_theme_tokens.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace { + +// Wraps a ColorRGBA8 token (from apple_theme_tokens.cc) as a UIColor. +inline UIColor* XEColorFromToken(const xe::ui::apple::ColorRGBA8& c) { + return [UIColor colorWithRed:c.Rf() green:c.Gf() blue:c.Bf() alpha:c.Af()]; +} + +// Returns the per-trait token RGBA8 for the given accessor. +// accessor: a lambda that pulls a ColorRGBA8 field from ThemeColorTokens. +inline UIColor* XEDynamicTokenColor( + xe::ui::apple::ColorRGBA8 (^accessor)(const xe::ui::apple::ThemeColorTokens&)) { + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + const auto& tokens = (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? xe::ui::apple::GetLightThemeTokens() + : xe::ui::apple::GetDarkThemeTokens(); + return XEColorFromToken(accessor(tokens.colors)); + }]; +} + +// Same pattern but pulls from the section-icon palette, which is part of the +// per-variant ThemeTokens (so dark/light pick different shades automatically). +inline UIColor* XEDynamicSectionIconColor( + xe::ui::apple::ColorRGBA8 (^accessor)(const xe::ui::apple::ThemeSectionIconTokens&)) { + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + const auto& tokens = (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? xe::ui::apple::GetLightThemeTokens() + : xe::ui::apple::GetDarkThemeTokens(); + return XEColorFromToken(accessor(tokens.section_icons)); + }]; +} + +// Picks the right shadow recipe for the given elevation. Same recipe in both +// variants; only the alpha shifts. +inline const xe::ui::apple::ThemeShadow& XEShadowForElevation(XeniaShadowElevation elevation) { + const auto& shadows = xe::ui::apple::GetDarkThemeTokens().shadows; + switch (elevation) { + case XeniaShadowElevationSubtle: + return shadows.subtle; + case XeniaShadowElevationMedium: + return shadows.medium; + case XeniaShadowElevationElevated: + return shadows.elevated; + } + return shadows.subtle; +} + +} // namespace + +XeniaShadowGeometry XeniaShadowGeometryForElevation(XeniaShadowElevation elevation) { + const auto& shadow = XEShadowForElevation(elevation); + return (XeniaShadowGeometry){ + .radius = shadow.radius, + .offset = CGSizeMake(shadow.offset_x, shadow.offset_y), + }; +} + +XeniaTextStyle XeniaTextStyleCaption2(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.caption2; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleCaption2}; +} +XeniaTextStyle XeniaTextStyleCaption1(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.caption1; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleCaption1}; +} +XeniaTextStyle XeniaTextStyleFootnote(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.footnote; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleFootnote}; +} +XeniaTextStyle XeniaTextStyleSubheadline(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.subheadline; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleSubheadline}; +} +XeniaTextStyle XeniaTextStyleBody(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.body; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleBody}; +} +XeniaTextStyle XeniaTextStyleHeadline(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.headline; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleHeadline}; +} +XeniaTextStyle XeniaTextStyleTitle3(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.title3; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleTitle3}; +} +XeniaTextStyle XeniaTextStyleTitle2(void) { + const auto& s = xe::ui::apple::GetDarkThemeTokens().typography_scale.title2; + return (XeniaTextStyle){s.point_size, s.weight, UIFontTextStyleTitle2}; +} + +@implementation XeniaTheme +// No static caching — this file is compiled under MRC (manual reference +// counting), so static locals would hold dangling pointers after the +// autorelease pool drains. UIColor creation is cheap; callers retain. +// +// Each accessor returns a +colorWithDynamicProvider: UIColor that resolves the +// concrete RGBA on access via the current trait collection's +// userInterfaceStyle. Dark/Light values come from +// apple_theme_tokens.cc (kThemeDark / kThemeLight). statusError / +// statusWarning aren't in the token table; their dark values match the +// previous hardcoded UI, and their light values are darker so they keep +// sufficient contrast on a white background. ++ (UIColor*)bgPrimary { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.bg_primary; + }); +} ++ (UIColor*)bgSurface { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.bg_surface; + }); +} ++ (UIColor*)bgSurface2 { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.bg_surface_2; + }); +} ++ (UIColor*)bgSurface3 { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.bg_surface_3; + }); +} ++ (UIColor*)textPrimary { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.text_primary; + }); +} ++ (UIColor*)textSecondary { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.text_secondary; + }); +} ++ (UIColor*)textMuted { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.text_muted; + }); +} ++ (UIColor*)accent { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.accent; + }); +} ++ (UIColor*)accentHover { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.accent_hover; + }); +} ++ (UIColor*)accentFg { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.accent_fg; + }); +} ++ (UIColor*)statusError { + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) ? XEColorFromHexRGB(0xdc2626) + : XEColorFromHexRGB(0xf87171); + }]; +} ++ (UIColor*)statusWarning { + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) ? XEColorFromHexRGB(0xd97706) + : XEColorFromHexRGB(0xfbbf24); + }]; +} ++ (UIColor*)border { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.border; + }); +} ++ (UIColor*)borderHover { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.border_hover; + }); +} ++ (UIColor*)overlay { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.overlay; + }); +} ++ (UIColor*)overlayLight { + return XEDynamicTokenColor(^(const xe::ui::apple::ThemeColorTokens& c) { + return c.overlay_light; + }); +} ++ (UIColor*)sectionIconBlue { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.blue; + }); +} ++ (UIColor*)sectionIconIndigo { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.indigo; + }); +} ++ (UIColor*)sectionIconOrange { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.orange; + }); +} ++ (UIColor*)sectionIconPurple { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.purple; + }); +} ++ (UIColor*)sectionIconRed { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.red; + }); +} ++ (UIColor*)sectionIconGray { + return XEDynamicSectionIconColor(^(const xe::ui::apple::ThemeSectionIconTokens& c) { + return c.gray; + }); +} ++ (UIColor*)touchTintAmber { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().amber); +} ++ (UIColor*)touchTintSky { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().sky); +} ++ (UIColor*)touchTintMint { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().mint); +} ++ (UIColor*)touchTintRose { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().rose); +} ++ (UIColor*)touchTintLime { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().lime); +} ++ (UIColor*)touchTintCoral { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().coral); +} ++ (UIColor*)touchTintSlate { + return XEColorFromToken(xe::ui::apple::GetTouchTintTokens().slate); +} ++ (UIColor*)shadowColorForElevation:(XeniaShadowElevation)elevation { + const auto& shadow = XEShadowForElevation(elevation); + const CGFloat alpha_dark = shadow.opacity_dark; + const CGFloat alpha_light = shadow.opacity_light; + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + const CGFloat alpha = (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? alpha_light + : alpha_dark; + return [UIColor colorWithWhite:0.0 alpha:alpha]; + }]; +} ++ (CGFloat)spacingXs { return xe::ui::apple::GetDarkThemeTokens().spacing.xs; } ++ (CGFloat)spacingSm { return xe::ui::apple::GetDarkThemeTokens().spacing.sm; } ++ (CGFloat)spacingMd { return xe::ui::apple::GetDarkThemeTokens().spacing.md; } ++ (CGFloat)spacingLg { return xe::ui::apple::GetDarkThemeTokens().spacing.lg; } ++ (CGFloat)spacingXl { return xe::ui::apple::GetDarkThemeTokens().spacing.xl; } ++ (CGFloat)spacingXxl { return xe::ui::apple::GetDarkThemeTokens().spacing.xxl; } ++ (CGFloat)opacitySubtle { return xe::ui::apple::GetDarkThemeTokens().opacity.subtle; } ++ (CGFloat)opacitySoft { return xe::ui::apple::GetDarkThemeTokens().opacity.soft; } ++ (CGFloat)opacityMedium { return xe::ui::apple::GetDarkThemeTokens().opacity.medium; } ++ (CGFloat)opacityStrong { return xe::ui::apple::GetDarkThemeTokens().opacity.strong; } ++ (CGFloat)opacityHeavy { return xe::ui::apple::GetDarkThemeTokens().opacity.heavy; } +@end diff --git a/src/xenia/ui/ios/shared/ios_theme_controls.h b/src/xenia/ui/ios/shared/ios_theme_controls.h new file mode 100644 index 000000000..511c65303 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_theme_controls.h @@ -0,0 +1,42 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_THEME_CONTROLS_H_ +#define XENIA_UI_IOS_THEME_CONTROLS_H_ + +#import + +// UILabel that adds an inset around its text. Useful for pill / chip styling. +@interface XeniaPaddedLabel : UILabel +@property(nonatomic) UIEdgeInsets padding; +@end + +// Dynamic-type aware font helpers. +UIFont* xe_scaled_system_font(UIFontTextStyle text_style, CGFloat point_size, UIFontWeight weight); +UIFont* xe_scaled_monospaced_font(UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight); +void xe_apply_label_font(UILabel* label, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight); +void xe_apply_monospaced_label_font(UILabel* label, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight); +void xe_apply_button_title_font(UIButton* button, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight); +void xe_apply_text_view_font(UITextView* text_view, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight, BOOL monospaced); + +// Reusable controls. +XeniaPaddedLabel* xe_make_tag_pill(NSString* text, UIColor* text_color); +UIButton* xe_make_ios_sheet_close_button(id target, SEL action); +UIImage* xe_settings_footer_image(NSString* asset_name, NSString* fallback_symbol_name, + BOOL tintable); +UIButton* xe_make_settings_footer_button(NSString* asset_name, NSString* fallback_symbol_name, + NSString* accessibility_label, NSInteger tag, + BOOL tintable, id target, SEL action); + +#endif // XENIA_UI_IOS_THEME_CONTROLS_H_ diff --git a/src/xenia/ui/ios/shared/ios_theme_controls.mm b/src/xenia/ui/ios/shared/ios_theme_controls.mm new file mode 100644 index 000000000..9b52ccb7a --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_theme_controls.mm @@ -0,0 +1,200 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_theme_controls.h" + +#import "xenia/ui/ios/shared/ios_theme.h" + +@implementation XeniaPaddedLabel + +- (CGSize)intrinsicContentSize { + CGSize size = [super intrinsicContentSize]; + return CGSizeMake(size.width + _padding.left + _padding.right, + size.height + _padding.top + _padding.bottom); +} + +- (void)drawTextInRect:(CGRect)rect { + [super drawTextInRect:UIEdgeInsetsInsetRect(rect, _padding)]; +} + +- (CGRect)textRectForBounds:(CGRect)bounds limitedToNumberOfLines:(NSInteger)numberOfLines { + CGRect inset = UIEdgeInsetsInsetRect(bounds, _padding); + CGRect result = [super textRectForBounds:inset limitedToNumberOfLines:numberOfLines]; + result.origin.x -= _padding.left; + result.origin.y -= _padding.top; + result.size.width += _padding.left + _padding.right; + result.size.height += _padding.top + _padding.bottom; + return result; +} + +@end + +UIFont* xe_scaled_system_font(UIFontTextStyle text_style, CGFloat point_size, UIFontWeight weight) { + UIFont* base_font = [UIFont systemFontOfSize:point_size weight:weight]; + return [[UIFontMetrics metricsForTextStyle:text_style] scaledFontForFont:base_font]; +} + +UIFont* xe_scaled_monospaced_font(UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight) { + UIFont* base_font = [UIFont monospacedSystemFontOfSize:point_size weight:weight]; + return [[UIFontMetrics metricsForTextStyle:text_style] scaledFontForFont:base_font]; +} + +void xe_apply_label_font(UILabel* label, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight) { + if (!label) { + return; + } + label.font = xe_scaled_system_font(text_style, point_size, weight); + if (@available(iOS 10.0, *)) { + label.adjustsFontForContentSizeCategory = YES; + } +} + +void xe_apply_monospaced_label_font(UILabel* label, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight) { + if (!label) { + return; + } + label.font = xe_scaled_monospaced_font(text_style, point_size, weight); + if (@available(iOS 10.0, *)) { + label.adjustsFontForContentSizeCategory = YES; + } +} + +void xe_apply_button_title_font(UIButton* button, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight) { + if (!button.titleLabel) { + return; + } + button.titleLabel.font = xe_scaled_system_font(text_style, point_size, weight); + if (@available(iOS 10.0, *)) { + button.titleLabel.adjustsFontForContentSizeCategory = YES; + } +} + +void xe_apply_text_view_font(UITextView* text_view, UIFontTextStyle text_style, CGFloat point_size, + UIFontWeight weight, BOOL monospaced) { + if (!text_view) { + return; + } + text_view.font = monospaced ? xe_scaled_monospaced_font(text_style, point_size, weight) + : xe_scaled_system_font(text_style, point_size, weight); + if (@available(iOS 10.0, *)) { + text_view.adjustsFontForContentSizeCategory = YES; + } +} + +XeniaPaddedLabel* xe_make_tag_pill(NSString* text, UIColor* text_color) { + XeniaPaddedLabel* pill = [[[XeniaPaddedLabel alloc] init] autorelease]; + pill.translatesAutoresizingMaskIntoConstraints = NO; + pill.padding = UIEdgeInsetsMake(1, 6, 1, 6); + xe_apply_label_font(pill, UIFontTextStyleCaption2, 10.0, UIFontWeightMedium); + pill.text = text ?: @""; + pill.textColor = text_color ?: [XeniaTheme textMuted]; + pill.backgroundColor = [(text_color ?: [XeniaTheme textMuted]) colorWithAlphaComponent:0.1]; + pill.layer.cornerRadius = 6.0; + pill.clipsToBounds = YES; + [pill setContentHuggingPriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + [pill setContentCompressionResistancePriority:UILayoutPriorityRequired + forAxis:UILayoutConstraintAxisHorizontal]; + return pill; +} + +// UIButton subclass that re-resolves its layer.borderColor when the host's +// userInterfaceStyle flips. CGColor is captured at assignment, so a plain +// dynamic-color initial assignment would freeze its dark variant for the +// lifetime of the button. Override traitCollectionDidChange: to refresh. +@interface XeniaSheetCloseButton : UIButton +@end + +@implementation XeniaSheetCloseButton +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if ([self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + self.layer.borderColor = + [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? [[UIColor blackColor] colorWithAlphaComponent:0.12] + : [[UIColor whiteColor] colorWithAlphaComponent:0.12]; + }].CGColor; + } +} +@end + +UIButton* xe_make_ios_sheet_close_button(id target, SEL action) { + XeniaSheetCloseButton* button = [XeniaSheetCloseButton buttonWithType:UIButtonTypeSystem]; + button.translatesAutoresizingMaskIntoConstraints = NO; + button.backgroundColor = [[XeniaTheme bgSurface] colorWithAlphaComponent:0.76]; + button.layer.cornerRadius = 24.0; + button.layer.borderWidth = 0.5; + button.layer.borderColor = + [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? [[UIColor blackColor] colorWithAlphaComponent:0.12] + : [[UIColor whiteColor] colorWithAlphaComponent:0.12]; + }].CGColor; + UIImageSymbolConfiguration* config = + [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightSemibold]; + UIImage* image = + [[UIImage systemImageNamed:@"xmark.circle"] imageByApplyingSymbolConfiguration:config]; + [button setImage:image forState:UIControlStateNormal]; + button.tintColor = [XeniaTheme accent]; + [button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; + [NSLayoutConstraint activateConstraints:@[ + [button.widthAnchor constraintEqualToConstant:48.0], + [button.heightAnchor constraintEqualToConstant:48.0], + ]]; + return button; +} + +UIImage* xe_settings_footer_image(NSString* asset_name, NSString* fallback_symbol_name, + BOOL tintable) { + UIImage* image = [UIImage imageNamed:asset_name]; + BOOL used_fallback = NO; + if (!image) { + used_fallback = YES; + UIImageSymbolConfiguration* config = + [UIImageSymbolConfiguration configurationWithPointSize:18.0 + weight:UIImageSymbolWeightMedium]; + image = [UIImage systemImageNamed:fallback_symbol_name withConfiguration:config]; + if (!image) { + image = [UIImage systemImageNamed:@"questionmark.circle" withConfiguration:config]; + } + } + return [image imageWithRenderingMode:(tintable || used_fallback) + ? UIImageRenderingModeAlwaysTemplate + : UIImageRenderingModeAlwaysOriginal]; +} + +UIButton* xe_make_settings_footer_button(NSString* asset_name, NSString* fallback_symbol_name, + NSString* accessibility_label, NSInteger tag, + BOOL tintable, id target, SEL action) { + UIImage* image = xe_settings_footer_image(asset_name, fallback_symbol_name, tintable); + UIButton* button = [UIButton buttonWithType:UIButtonTypeCustom]; + button.translatesAutoresizingMaskIntoConstraints = NO; + button.tag = tag; + button.backgroundColor = [UIColor clearColor]; + button.tintColor = [XeniaTheme textPrimary]; + [button setImage:image forState:UIControlStateNormal]; + button.contentEdgeInsets = UIEdgeInsetsMake(6.0, 6.0, 6.0, 6.0); + button.adjustsImageWhenHighlighted = YES; + button.imageView.contentMode = UIViewContentModeScaleAspectFit; + button.accessibilityLabel = accessibility_label; + button.accessibilityHint = @"Opens in Safari."; + button.accessibilityTraits |= UIAccessibilityTraitLink; + button.largeContentTitle = accessibility_label; + button.showsLargeContentViewer = YES; + [button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; + [button.widthAnchor constraintEqualToConstant:44.0].active = YES; + [button.heightAnchor constraintEqualToConstant:44.0].active = YES; + return button; +} diff --git a/src/xenia/ui/ios/shared/ios_view_helpers.h b/src/xenia/ui/ios/shared/ios_view_helpers.h new file mode 100644 index 000000000..39c535746 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_view_helpers.h @@ -0,0 +1,131 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_VIEW_HELPERS_H_ +#define XENIA_UI_IOS_VIEW_HELPERS_H_ + +#import + +#include +#include + +#import "xenia/ui/ios/shared/ios_theme.h" + +// Shared ObjC-side helpers for the iOS UI module: bridges from std::string +// to NSString, the standard "OK" alert presenter, and sheet base classes +// that own the orientation overrides every Xenia modal sheet shares. + +NSString* ToNSString(const std::string& value); + +// Title ID → 8-digit hex NSString (zero-padded), uppercase or lowercase. +NSString* XEFormatTitleIDHexUpper(uint32_t title_id); +NSString* XEFormatTitleIDHexLower(uint32_t title_id); + +// Builds a fully opaque UIColor from a 0xRRGGBB packed value. +UIColor* XEColorFromHexRGB(uint32_t rgb); + +// Applies common VoiceOver metadata. Pass nil for values that are not needed. +void XEApplyAccessibility(UIView* view, NSString* label, NSString* value, NSString* hint, + UIAccessibilityTraits traits); + +// Configures a UINavigationController as a XeniOS task sheet. On compact +// height devices the sheet attaches to the screen edge and follows +// preferredContentSize so landscape iPhone sheets don't stretch into a +// cut-off fullscreen bar. +void XEConfigureTaskSheet(UINavigationController* navigation_controller, UIView* host_view, + CGSize preferred_size, BOOL prevent_interactive_dismissal); + +// Configures a controller as a full destination for hierarchical app areas +// such as Settings and Profile. Unlike XEConfigureTaskSheet, this never uses a +// medium detent: compact/iPhone presentations are fullscreen, while iPad +// presentations use a large-only page sheet. +void XEConfigureDestinationControllerPresentation(UIViewController* view_controller, + UIView* host_view, CGSize preferred_size, + BOOL prevent_interactive_dismissal); + +// Convenience wrapper for navigation-controller-backed destinations. +void XEConfigureDestinationPresentation(UINavigationController* navigation_controller, + UIView* host_view, CGSize preferred_size, + BOOL prevent_interactive_dismissal); + +// Returns an autoreleased UIVisualEffect suitable as the backdrop for a +// floating chrome surface (in-game menu, touch overlay editor, status toast, +// achievement banner). Hand the result straight to +// `-[UIVisualEffectView initWithEffect:]` which retains it. +// +// On iOS 26+ this returns a `UIGlassEffect` (regular or clear variant per +// `clear_variant`). On iOS 18-25 the fallback is +// `UIBlurEffectStyleSystemMaterial` regardless of `clear_variant` — +// pre-iOS-26 callers see the same backdrop they got before this helper +// existed. +UIVisualEffect* xe_make_chrome_visual_effect(BOOL clear_variant); + +// Configures `view` with the standard floating-window styling used across the +// iOS chrome (touch editor, in-game menu, etc.): +// +// * 20pt corner radius +// * 1pt hairline border (10%-white in dark mode, 10%-black in light mode) +// * Soft 8pt shadow at 14% black, offset (0, 8) +// * Liquid-glass backdrop on iOS 26+ (regular variant), with +// UIBlurEffectStyleSystemMaterial as the iOS 18-25 fallback. Picked by +// xe_make_chrome_visual_effect(). +// +// `view` should be a plain UIView with `backgroundColor = clearColor`. The +// helper inserts the blur as the bottommost subview so any subsequent +// addSubview: lands on top. +// +// Internally the view is converted to an XeniaFloatingWindowView subclass +// which observes -traitCollectionDidChange: and refreshes its border-color +// CGColor when userInterfaceStyle flips. +void xe_apply_floating_window_chrome(UIView* view); + +// Lighter cousin of xe_apply_floating_window_chrome for less-elevated +// surfaces (status toast, achievement banner, settings restart-notice card): +// +// * XeniaRadiusLg (12pt) corner radius +// * 1pt hairline border, same dark/light dynamic as the floating window +// * No shadow — caller layers one on via xe_apply_shadow_token(). +// * Backdrop picked by xe_make_chrome_visual_effect(clear_variant). For +// the toast/banner use case pass NO (regular glass on iOS 26). +// +// `view` should be a plain UIView with `backgroundColor = clearColor` and the +// caller is responsible for inserting content subviews above the inserted +// backdrop (which lands at index 0). +void xe_apply_glass_card_chrome(UIView* view, BOOL clear_variant); + +// Applies the tokenised shadow recipe for the given elevation to view.layer. +// shadowColor is [XeniaTheme shadowColorForElevation:elevation] so dark/light +// trait flips read the right alpha; shadowOpacity stays 1.0. The CGColor is +// re-resolved on -traitCollectionDidChange: through a hidden trait observer +// attached to `view` (same pattern as xe_apply_floating_window_chrome). +void xe_apply_shadow_token(UIView* view, XeniaShadowElevation elevation); + +// Hidden zero-frame UIView that forwards -traitCollectionDidChange: into a +// block. Used internally by xe_apply_floating_window_chrome to keep the +// floating-window border / blur in sync with the trait collection without +// changing the helper's existing UIView* signature. +@interface XeniaTraitObserverView : UIView +@property(nonatomic, copy) void (^onTraitChange)(UITraitCollection* previousTraits); +@end + +// Presents a one-button "OK" alert. No-ops when `presenter` is nil. +void XEPresentOKAlert(UIViewController* presenter, NSString* title, NSString* message); + +// UITableViewController base class with the orientation overrides every +// Xenia sheet shares: rotates freely (no upside-down) and starts in +// whatever orientation the host launcher is currently using. +@interface XESheetTableViewController : UITableViewController +@end + +// UIViewController base class with the same orientation overrides as +// XESheetTableViewController. +@interface XESheetViewController : UIViewController +@end + +#endif // XENIA_UI_IOS_VIEW_HELPERS_H_ diff --git a/src/xenia/ui/ios/shared/ios_view_helpers.mm b/src/xenia/ui/ios/shared/ios_view_helpers.mm new file mode 100644 index 000000000..efa80f392 --- /dev/null +++ b/src/xenia/ui/ios/shared/ios_view_helpers.mm @@ -0,0 +1,323 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_theme.h" + +NSString* ToNSString(const std::string& value) { + return [NSString stringWithUTF8String:value.c_str()]; +} + +NSString* XEFormatTitleIDHexUpper(uint32_t title_id) { + return [NSString stringWithFormat:@"%08X", title_id]; +} + +NSString* XEFormatTitleIDHexLower(uint32_t title_id) { + return [NSString stringWithFormat:@"%08x", title_id]; +} + +UIColor* XEColorFromHexRGB(uint32_t rgb) { + return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0 + green:((rgb >> 8) & 0xFF) / 255.0 + blue:(rgb & 0xFF) / 255.0 + alpha:1.0]; +} + +void XEApplyAccessibility(UIView* view, NSString* label, NSString* value, + NSString* hint, UIAccessibilityTraits traits) { + if (!view) { + return; + } + view.isAccessibilityElement = label.length > 0 || value.length > 0 || + hint.length > 0 || traits != UIAccessibilityTraitNone; + view.accessibilityLabel = label; + view.accessibilityValue = value; + view.accessibilityHint = hint; + if (traits != UIAccessibilityTraitNone) { + view.accessibilityTraits = traits; + } +} + +void XEConfigureTaskSheet(UINavigationController* navigation_controller, + UIView* host_view, CGSize preferred_size, + BOOL prevent_interactive_dismissal) { + if (!navigation_controller) { + return; + } + navigation_controller.navigationBar.prefersLargeTitles = NO; + navigation_controller.preferredContentSize = preferred_size; + navigation_controller.modalInPresentation = prevent_interactive_dismissal; + + const CGSize bounds_size = host_view ? host_view.bounds.size : UIScreen.mainScreen.bounds.size; + const BOOL landscape_presentation = bounds_size.width > bounds_size.height; + navigation_controller.modalPresentationStyle = + (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) ? UIModalPresentationFormSheet + : UIModalPresentationPageSheet; + if (@available(iOS 15.0, *)) { + UISheetPresentationController* sheet = navigation_controller.sheetPresentationController; + sheet.detents = landscape_presentation + ? @[ [UISheetPresentationControllerDetent largeDetent] ] + : @[ + [UISheetPresentationControllerDetent mediumDetent], + [UISheetPresentationControllerDetent largeDetent] + ]; + sheet.prefersGrabberVisible = YES; + sheet.prefersScrollingExpandsWhenScrolledToEdge = YES; + if (UI_USER_INTERFACE_IDIOM() != UIUserInterfaceIdiomPad) { + sheet.prefersEdgeAttachedInCompactHeight = YES; + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = YES; + } + } +} + +void XEConfigureDestinationControllerPresentation( + UIViewController* view_controller, UIView* host_view, + CGSize preferred_size, BOOL prevent_interactive_dismissal) { + if (!view_controller) { + return; + } + + const CGSize bounds_size = + host_view ? host_view.bounds.size : UIScreen.mainScreen.bounds.size; + const UITraitCollection* traits = + host_view ? host_view.traitCollection : UIScreen.mainScreen.traitCollection; + const BOOL compact_width = + traits.horizontalSizeClass == UIUserInterfaceSizeClassCompact; + const BOOL fullscreen = + UI_USER_INTERFACE_IDIOM() != UIUserInterfaceIdiomPad || compact_width; + + CGSize content_size = preferred_size; + if (CGSizeEqualToSize(content_size, CGSizeZero)) { + content_size = bounds_size; + } + if (!fullscreen && bounds_size.height > 0.0) { + content_size.height = MAX(content_size.height, bounds_size.height - 24.0); + } + + if ([view_controller isKindOfClass:[UINavigationController class]]) { + UINavigationController* navigation_controller = + (UINavigationController*)view_controller; + navigation_controller.navigationBar.prefersLargeTitles = NO; + } + view_controller.preferredContentSize = content_size; + view_controller.modalInPresentation = prevent_interactive_dismissal; + view_controller.modalPresentationStyle = + fullscreen ? UIModalPresentationFullScreen : UIModalPresentationPageSheet; + + if (@available(iOS 15.0, *)) { + UISheetPresentationController* sheet = + view_controller.sheetPresentationController; + if (sheet) { + sheet.detents = @[ [UISheetPresentationControllerDetent largeDetent] ]; + sheet.selectedDetentIdentifier = + UISheetPresentationControllerDetentIdentifierLarge; + sheet.prefersGrabberVisible = NO; + sheet.prefersScrollingExpandsWhenScrolledToEdge = YES; + sheet.prefersEdgeAttachedInCompactHeight = NO; + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = NO; + } + } +} + +void XEConfigureDestinationPresentation( + UINavigationController* navigation_controller, UIView* host_view, + CGSize preferred_size, BOOL prevent_interactive_dismissal) { + XEConfigureDestinationControllerPresentation(navigation_controller, host_view, + preferred_size, + prevent_interactive_dismissal); +} + +@implementation XeniaTraitObserverView + +- (instancetype)initWithFrame:(CGRect)frame { + if (!(self = [super initWithFrame:frame])) { + return nil; + } + self.userInteractionEnabled = NO; + self.hidden = YES; + return self; +} + +- (void)dealloc { + [_onTraitChange release]; + [super dealloc]; +} + +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if (self.onTraitChange && + [self.traitCollection + hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + self.onTraitChange(previousTraitCollection); + } +} + +@end + +namespace { + +// Returns the floating-window border CGColor for the given trait collection. +// 10% white in dark mode, 10% black in light mode. +inline UIColor* xe_floating_window_border_color() { + return [UIColor colorWithDynamicProvider:^UIColor*(UITraitCollection* traits) { + return (traits.userInterfaceStyle == UIUserInterfaceStyleLight) + ? [UIColor colorWithWhite:0.0 alpha:0.10] + : [UIColor colorWithWhite:1.0 alpha:0.10]; + }]; +} + +// Inserts a UIVisualEffectView wrapping `effect` at index 0 of `host`, sized +// to fill, with the same corner radius as the host's layer. Returns the +// inserted view (autoreleased) for callers that need to reference it. +UIVisualEffectView* xe_install_chrome_backdrop(UIView* host, UIVisualEffect* effect) { + UIVisualEffectView* backdrop = [[UIVisualEffectView alloc] initWithEffect:effect]; + backdrop.frame = host.bounds; + backdrop.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + backdrop.layer.cornerRadius = host.layer.cornerRadius; + backdrop.layer.masksToBounds = YES; + backdrop.userInteractionEnabled = NO; + [host insertSubview:backdrop atIndex:0]; + [backdrop autorelease]; + return backdrop; +} + +} // namespace + +UIVisualEffect* xe_make_chrome_visual_effect(BOOL clear_variant) { + if (@available(iOS 26.0, *)) { + UIGlassEffectStyle style = + clear_variant ? UIGlassEffectStyleClear : UIGlassEffectStyleRegular; + return [UIGlassEffect effectWithStyle:style]; + } + // iOS 18-25 fallback: the closest pre-glass UIBlurEffect for each variant. + // SystemUltraThinMaterial reads as the most transparent, suitable for + // surfaces sitting on top of rich content (clear-variant callers like the + // compat hero over game artwork). SystemMaterial is the standard chrome + // material every other caller has been using. + UIBlurEffectStyle fallback_style = clear_variant + ? UIBlurEffectStyleSystemUltraThinMaterial + : UIBlurEffectStyleSystemMaterial; + return [UIBlurEffect effectWithStyle:fallback_style]; +} + +void xe_apply_floating_window_chrome(UIView* view) { + if (!view) { + return; + } + view.backgroundColor = [UIColor clearColor]; + view.layer.cornerRadius = 20.0; + view.layer.borderWidth = 1.0; + view.layer.borderColor = xe_floating_window_border_color().CGColor; + view.layer.shadowColor = [UIColor blackColor].CGColor; + view.layer.shadowOpacity = 0.14f; + view.layer.shadowRadius = 8.0f; + view.layer.shadowOffset = CGSizeMake(0.0f, 8.0f); + // The backdrop sits inside the floating view as a child UIVisualEffectView + // so the shadow can render outside the masked-to-bounds layer. Picked by + // xe_make_chrome_visual_effect — liquid glass on iOS 26+, SystemMaterial + // on iOS 18-25. + xe_install_chrome_backdrop(view, xe_make_chrome_visual_effect(NO)); + + // Refresh the border CGColor on trait flips. The visual-effect view picks + // up the new dark/light variant automatically when the trait collection + // updates, so only the layer's frozen CGColor needs an explicit re-poke. + // The observer is added as a subview of `view`, so its lifetime never + // exceeds the host's. Capturing weakly (__unsafe_unretained) avoids + // forming a retain cycle while remaining safe under MRC. + XeniaTraitObserverView* observer = [[XeniaTraitObserverView alloc] initWithFrame:CGRectZero]; + __unsafe_unretained UIView* weak_view = view; + observer.onTraitChange = ^(UITraitCollection* __unused previous) { + weak_view.layer.borderColor = xe_floating_window_border_color().CGColor; + }; + [view addSubview:observer]; + [observer release]; +} + +void xe_apply_glass_card_chrome(UIView* view, BOOL clear_variant) { + if (!view) { + return; + } + view.backgroundColor = [UIColor clearColor]; + view.layer.cornerRadius = XeniaRadiusLg; + view.layer.borderWidth = 1.0; + view.layer.borderColor = xe_floating_window_border_color().CGColor; + xe_install_chrome_backdrop(view, xe_make_chrome_visual_effect(clear_variant)); + + XeniaTraitObserverView* observer = + [[XeniaTraitObserverView alloc] initWithFrame:CGRectZero]; + __unsafe_unretained UIView* weak_view = view; + observer.onTraitChange = ^(UITraitCollection* __unused previous) { + weak_view.layer.borderColor = xe_floating_window_border_color().CGColor; + }; + [view addSubview:observer]; + [observer release]; +} + +void xe_apply_shadow_token(UIView* view, XeniaShadowElevation elevation) { + if (!view) { + return; + } + const XeniaShadowGeometry geometry = XeniaShadowGeometryForElevation(elevation); + view.layer.shadowColor = [XeniaTheme shadowColorForElevation:elevation].CGColor; + view.layer.shadowOpacity = 1.0f; + view.layer.shadowRadius = geometry.radius; + view.layer.shadowOffset = geometry.offset; + + // shadowColor is a frozen CGColor — refresh it on trait flips so the + // dark/light alpha switch actually takes effect. + XeniaTraitObserverView* observer = + [[XeniaTraitObserverView alloc] initWithFrame:CGRectZero]; + __unsafe_unretained UIView* weak_view = view; + observer.onTraitChange = ^(UITraitCollection* __unused previous) { + weak_view.layer.shadowColor = + [XeniaTheme shadowColorForElevation:elevation].CGColor; + }; + [view addSubview:observer]; + [observer release]; +} + +void XEPresentOKAlert(UIViewController* presenter, NSString* title, NSString* message) { + if (!presenter) { + return; + } + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title ?: @"Notice" + message:message ?: @"" + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleCancel + handler:nil]]; + [presenter presentViewController:alert animated:YES completion:nil]; +} + +@implementation XESheetTableViewController + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskAll; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return xe_current_interface_orientation(self.view); +} + +@end + +@implementation XESheetViewController + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskAll; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return xe_current_interface_orientation(self.view); +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_control_shell_view_ios.h b/src/xenia/ui/ios/touch/touch_control_shell_view_ios.h new file mode 100644 index 000000000..a81c0d5ce --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_control_shell_view_ios.h @@ -0,0 +1,30 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_CONTROL_SHELL_VIEW_IOS_H_ +#define XENIA_UI_IOS_TOUCH_CONTROL_SHELL_VIEW_IOS_H_ + +#import + +#include "xenia/hid/touch/touch_layout_ios.h" + +@interface XeniaTouchControlShellView : UIView + +- (instancetype)initWithControl:(const xe::hid::touch::IOSTouchControlDefinition&)control; +- (void)applyControlDefinition:(const xe::hid::touch::IOSTouchControlDefinition&)control; +- (void)setTouchActive:(BOOL)active; +- (void)setConflictHighlighted:(BOOL)highlighted; +// When YES, the shell suppresses all visible chrome (fill, border, label). +// Used for full-screen Look swipe zones during gameplay so the player's view +// of the game isn't obscured. The shell still receives touches. +- (void)setChromeSuppressed:(BOOL)suppressed; + +@end + +#endif // XENIA_UI_IOS_TOUCH_CONTROL_SHELL_VIEW_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_control_shell_view_ios.mm b/src/xenia/ui/ios/touch/touch_control_shell_view_ios.mm new file mode 100644 index 000000000..4d2e83e0e --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_control_shell_view_ios.mm @@ -0,0 +1,285 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_control_shell_view_ios.h" + +#include + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_theme_controls.h" +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +@implementation XeniaTouchControlShellView { + UILabel* label_; + xe::hid::touch::IOSTouchControlDefinition control_; + BOOL touch_active_; + BOOL conflict_highlighted_; + BOOL chrome_suppressed_; + UIImageView* dpad_arrow_up_; + UIImageView* dpad_arrow_down_; + UIImageView* dpad_arrow_left_; + UIImageView* dpad_arrow_right_; +} + +- (instancetype)initWithControl: + (const xe::hid::touch::IOSTouchControlDefinition&)control { + if (!(self = [super initWithFrame:CGRectZero])) { + return nil; + } + + self.backgroundColor = [UIColor clearColor]; + self.userInteractionEnabled = NO; + self.layer.borderWidth = 1.5; + + label_ = [[UILabel alloc] initWithFrame:CGRectZero]; + label_.backgroundColor = [UIColor clearColor]; + label_.textAlignment = NSTextAlignmentCenter; + xe_apply_label_font(label_, UIFontTextStyleCaption1, 12.0, + UIFontWeightSemibold); + label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.92]; + label_.adjustsFontSizeToFitWidth = YES; + label_.minimumScaleFactor = 0.6f; + [self addSubview:label_]; + + [self applyControlDefinition:control]; + return self; +} + +- (void)dealloc { + [dpad_arrow_right_ release]; + [dpad_arrow_left_ release]; + [dpad_arrow_down_ release]; + [dpad_arrow_up_ release]; + [label_ release]; + [super dealloc]; +} + +- (void)ensureDpadArrowViews { + if (dpad_arrow_up_) { + return; + } + UIImageSymbolConfiguration* arrow_config = + [UIImageSymbolConfiguration configurationWithPointSize:18.0 + weight:UIImageSymbolWeightBold]; + UIImage* up_image = + [UIImage systemImageNamed:@"chevron.up" withConfiguration:arrow_config]; + UIImage* down_image = + [UIImage systemImageNamed:@"chevron.down" withConfiguration:arrow_config]; + UIImage* left_image = + [UIImage systemImageNamed:@"chevron.left" withConfiguration:arrow_config]; + UIImage* right_image = + [UIImage systemImageNamed:@"chevron.right" withConfiguration:arrow_config]; + dpad_arrow_up_ = [[UIImageView alloc] initWithImage:up_image]; + dpad_arrow_down_ = [[UIImageView alloc] initWithImage:down_image]; + dpad_arrow_left_ = [[UIImageView alloc] initWithImage:left_image]; + dpad_arrow_right_ = [[UIImageView alloc] initWithImage:right_image]; + for (UIImageView* arrow in + @[ dpad_arrow_up_, dpad_arrow_down_, dpad_arrow_left_, dpad_arrow_right_ ]) { + arrow.contentMode = UIViewContentModeCenter; + arrow.userInteractionEnabled = NO; + arrow.alpha = 0.85f; + arrow.tintColor = [[UIColor whiteColor] colorWithAlphaComponent:0.92]; + arrow.hidden = YES; + [self addSubview:arrow]; + } +} + +- (BOOL)isMoveDpadComboControl { + return control_.type == xe::hid::touch::IOSTouchControlType::kMoveStick && + control_.move_with_dpad_ring; +} + +- (void)applyControlDefinition: + (const xe::hid::touch::IOSTouchControlDefinition&)control { + control_ = control; + + NSString* label_text = xe::ui::XeniaTouchVisibleControlLabelText(control_, NO); + label_.text = label_text; + label_.hidden = label_text.length == 0; + + if ([self isMoveDpadComboControl]) { + [self ensureDpadArrowViews]; + UIColor* tint = + xe::ui::XeniaTouchOverlayAccentColor(control_.tint_style, control_.type); + for (UIImageView* arrow in + @[ dpad_arrow_up_, dpad_arrow_down_, dpad_arrow_left_, dpad_arrow_right_ ]) { + arrow.tintColor = tint; + arrow.hidden = NO; + } + } else { + dpad_arrow_up_.hidden = YES; + dpad_arrow_down_.hidden = YES; + dpad_arrow_left_.hidden = YES; + dpad_arrow_right_.hidden = YES; + } + + touch_active_ = NO; + [self refreshVisualState]; + [self setNeedsLayout]; +} + +- (CGFloat)baseVisualAlpha { + return static_cast( + control_.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone + ? 1.0f + : std::clamp(control_.visual_opacity, 0.0f, 1.0f)); +} + +- (void)refreshVisualState { + if (chrome_suppressed_) { + [UIView animateWithDuration:0.10 + delay:0.0 + options:(UIViewAnimationOptions)(UIViewAnimationOptionCurveEaseOut | + UIViewAnimationOptionAllowUserInteraction | + UIViewAnimationOptionBeginFromCurrentState) + animations:^{ + self.alpha = 0.0; + self.backgroundColor = [UIColor clearColor]; + self.transform = CGAffineTransformIdentity; + } + completion:nil]; + [CATransaction begin]; + [CATransaction setAnimationDuration:0.10]; + [CATransaction setAnimationTimingFunction: + [CAMediaTimingFunction functionWithName: + kCAMediaTimingFunctionEaseOut]]; + self.layer.borderColor = [UIColor clearColor].CGColor; + self.layer.borderWidth = 0.0; + [CATransaction commit]; + return; + } + + const CGFloat base_alpha = [self baseVisualAlpha]; + const CGFloat active_alpha = MIN(base_alpha + 0.18f, 1.0f); + const CGFloat target_alpha = touch_active_ ? active_alpha : base_alpha; + CGFloat fill_alpha = touch_active_ ? MIN(base_alpha + 0.45f, 1.0f) : base_alpha; + if (control_.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + fill_alpha = touch_active_ ? 0.55f : 0.18f; + } + UIColor* target_fill = + xe::ui::XeniaTouchOverlayFillColorForControl(control_, fill_alpha); + UIColor* target_border = + xe::ui::XeniaTouchOverlayBorderColorForControl(control_); + if (conflict_highlighted_) { + target_border = [[XeniaTheme statusError] colorWithAlphaComponent:0.95]; + } + if (touch_active_) { + target_border = + control_.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone + ? xe::ui::XeniaTouchOverlayAccentColor(control_.tint_style, + control_.type) + : [[UIColor whiteColor] colorWithAlphaComponent:0.78]; + } + const CGFloat target_border_width = + control_.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone + ? (touch_active_ ? 1.8f : 1.0f) + : (touch_active_ ? 2.0f : 1.5f); + const CGAffineTransform target_transform = + touch_active_ ? CGAffineTransformMakeScale(1.03f, 1.03f) + : CGAffineTransformIdentity; + + [UIView animateWithDuration:0.08 + delay:0.0 + options:(UIViewAnimationOptions)(UIViewAnimationOptionCurveEaseOut | + UIViewAnimationOptionAllowUserInteraction | + UIViewAnimationOptionBeginFromCurrentState) + animations:^{ + self.alpha = target_alpha; + self.backgroundColor = target_fill; + self.transform = target_transform; + } + completion:nil]; + + [CATransaction begin]; + [CATransaction setAnimationDuration:0.08]; + [CATransaction setAnimationTimingFunction: + [CAMediaTimingFunction functionWithName: + kCAMediaTimingFunctionEaseOut]]; + self.layer.borderColor = target_border.CGColor; + self.layer.borderWidth = target_border_width; + [CATransaction commit]; +} + +- (void)setTouchActive:(BOOL)active { + if (touch_active_ == active) { + return; + } + touch_active_ = active; + [self refreshVisualState]; +} + +- (void)setConflictHighlighted:(BOOL)highlighted { + if (conflict_highlighted_ == highlighted) { + return; + } + conflict_highlighted_ = highlighted; + [self refreshVisualState]; +} + +- (void)setChromeSuppressed:(BOOL)suppressed { + if (chrome_suppressed_ == suppressed) { + return; + } + chrome_suppressed_ = suppressed; + label_.hidden = suppressed || (label_.text.length == 0); + [self refreshVisualState]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + CGFloat corner_radius = 16.0f; + if (control_.shape == xe::hid::touch::IOSTouchControlShape::kCircle) { + corner_radius = + MIN(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)) * 0.5f; + } else if (control_.type == + xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + corner_radius = 22.0f; + } + self.layer.cornerRadius = corner_radius; + + label_.frame = CGRectInset(self.bounds, 8.0f, 8.0f); + if (control_.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + xe_apply_label_font(label_, UIFontTextStyleCaption2, 11.0, + UIFontWeightMedium); + label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.30]; + } else { + xe_apply_label_font(label_, UIFontTextStyleCaption1, 12.0, + UIFontWeightSemibold); + label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.92]; + } + + if ([self isMoveDpadComboControl] && dpad_arrow_up_) { + const CGFloat short_side = + MIN(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)); + const CGFloat stick_radius = + short_side * xe::ui::kXeniaTouchComboStickRadiusFraction; + const CGFloat outer_radius = short_side * 0.5f; + const CGFloat arrow_radius = (stick_radius + outer_radius) * 0.5f; + const CGFloat arrow_size = MAX(short_side * 0.18f, 24.0f); + const CGFloat centre_x = CGRectGetMidX(self.bounds); + const CGFloat centre_y = CGRectGetMidY(self.bounds); + dpad_arrow_up_.frame = + CGRectMake(centre_x - arrow_size * 0.5f, + centre_y - arrow_radius - arrow_size * 0.5f, arrow_size, + arrow_size); + dpad_arrow_down_.frame = + CGRectMake(centre_x - arrow_size * 0.5f, + centre_y + arrow_radius - arrow_size * 0.5f, arrow_size, + arrow_size); + dpad_arrow_left_.frame = + CGRectMake(centre_x - arrow_radius - arrow_size * 0.5f, + centre_y - arrow_size * 0.5f, arrow_size, arrow_size); + dpad_arrow_right_.frame = + CGRectMake(centre_x + arrow_radius - arrow_size * 0.5f, + centre_y - arrow_size * 0.5f, arrow_size, arrow_size); + } +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h b/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h new file mode 100644 index 000000000..f3c49c0cb --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h @@ -0,0 +1,108 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_CONTROLS_OVERLAY_HELPERS_IOS_H_ +#define XENIA_UI_IOS_TOUCH_CONTROLS_OVERLAY_HELPERS_IOS_H_ + +#ifdef __OBJC__ + +#import + +#include +#include +#include + +#include "xenia/hid/touch/touch_input_resolver.h" +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe::ui::ios::touch_overlay { + +inline constexpr float kTouchAxisMax = 32767.0f; +inline constexpr CGFloat kEditGridSpacingPoints = 28.0f; +inline constexpr CGFloat kEditGridDotRadius = 1.15f; +inline constexpr CGFloat kEditMoveSnapThresholdPoints = 18.0f; +inline constexpr CGFloat kEditResizeSnapThresholdPoints = 14.0f; +inline constexpr CGFloat kEditGridMoveSnapThresholdPoints = 32.0f; +inline constexpr CGFloat kEditGridResizeSnapThresholdPoints = 24.0f; +inline constexpr CGFloat kEditSnapGuideLineWidth = 1.25f; +inline constexpr CGFloat kEditChromeHeaderHeight = 44.0f; +inline constexpr NSInteger kEditChromeDockAuto = -1; +inline constexpr NSInteger kEditChromeDockCount = 8; + +extern const float kEditCanonicalControlSizes[9]; +extern const float kLookZoneScaleChoices[8]; +extern const float kRelativeLookScaleChoices[8]; +extern const xe::hid::touch::IOSTouchControlShape kEditShapeChoices[2]; + +struct TouchCaptureState { + enum class EditGestureMode : uint8_t { + kMove = 0, + kResize, + }; + + enum class ComboSubzone : uint8_t { + kNone = 0, + kStick, + kDpadUp, + kDpadDown, + kDpadLeft, + kDpadRight, + }; + + UITouch* touch = nil; + NSUInteger control_index = NSNotFound; + CGPoint anchor_point = CGPointZero; + CGPoint current_point = CGPointZero; + CFTimeInterval began_time = 0.0; + bool secondary_behavior_triggered = false; + xe::hid::touch::IOSTouchRect normalized_frame_at_capture; + EditGestureMode edit_gesture_mode = EditGestureMode::kMove; + ComboSubzone combo_subzone = ComboSubzone::kNone; +}; + +using TouchInteractionBehaviorState = xe::hid::touch::IOSTouchInteractionBehaviorState; + +float TouchLookPointsPerFullScale(); +float TouchLookVerticalScale(); +float TouchLookHoldSeconds(); +float TouchButtonTapHoldSeconds(); +CGPoint ClampLookVector(CGPoint value); +CGPoint SwipeLookVectorForDelta(CGPoint delta, float look_scale); + +std::array EditChromeDockCandidateFrames( + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, CGFloat chrome_margin, + CGFloat chrome_width, CGFloat chrome_height); +TouchCaptureState::ComboSubzone TouchComboSubzoneForPoint( + const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& resolved_frame, CGPoint point); +bool TouchControlContainsPoint(const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& resolved_frame, CGPoint point); +TouchInteractionBehaviorState ResolveTouchInteractionBehaviorState( + const xe::hid::touch::IOSTouchInteractionBehavior& behavior, const TouchCaptureState& capture, + CFTimeInterval current_time); +NSString* TouchInteractionBehaviorSummaryText( + const xe::hid::touch::IOSTouchInteractionBehavior& behavior); +CGPoint MoveStickUnitVectorForCapture(const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& frame, + const TouchCaptureState& capture); +bool MoveStickCaptureQualifiesForDoubleTapForward( + const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& frame, const TouchCaptureState& capture, + CFTimeInterval current_time); +NSString* TouchButtonMaskPreviewText(uint16_t buttons); +NSString* TouchControlShapeDisplayText(xe::hid::touch::IOSTouchControlShape shape); +CGFloat TouchEditPreviewHeightForLabel(UILabel* label, CGFloat width); +CGFloat LayoutVisibleButtonsRow(CGFloat y, CGFloat x, CGFloat width, CGFloat height, CGFloat gap, + NSArray* buttons); + +} // namespace xe::ui::ios::touch_overlay + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_TOUCH_CONTROLS_OVERLAY_HELPERS_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.mm b/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.mm new file mode 100644 index 000000000..b12476cd2 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.mm @@ -0,0 +1,328 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h" + +#include + +#include "xenia/base/cvar.h" +#include "xenia/hid/input.h" +#include "xenia/hid/touch/touch_layout_editor.h" +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +DEFINE_bool(ios_touch_haptics, true, + "Play haptic feedback (UIImpactFeedbackGenerator / " + "UISelectionFeedbackGenerator) on touch button presses, control selection, and snap " + "engagement. Disable for silent / accessibility setups.", + "iOS"); +DEFINE_bool(ios_touch_overlay, true, + "Show the on-screen iOS touch controls during gameplay when no hardware controller is " + "connected. Disable to remove the gameplay overlay while leaving the editor available.", + "iOS"); +DEFINE_double(ios_touch_look_points_per_full_scale, 4.0, + "Points of swipe motion needed to drive the touch look zone to full stick output. " + "Lower values make the camera swipe more sensitive.", + "iOS"); +DEFINE_double(ios_touch_look_vertical_scale, 1.20, + "Extra multiplier for vertical swipe-look motion. Touch aiming often feels slower " + "vertically because many games apply lower pitch sensitivity than yaw.", + "iOS"); +DEFINE_double(ios_touch_look_hold_seconds, 0.10, + "How long touch look motion should persist so low-FPS gameplay polls can observe it. " + "Lower values reduce camera tail; higher values improve capture at low frame rates.", + "iOS"); +DEFINE_double(ios_touch_button_tap_hold_seconds, 0.10, + "How long tap-style touch buttons stay active so low-FPS gameplay polls can observe " + "quick presses like jump or reload.", + "iOS"); + +namespace xe::ui::ios::touch_overlay { + +namespace { + +void AppendTouchButtonMaskNames(uint16_t buttons, NSMutableArray* parts) { + if (!parts) { + return; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_A) { + [parts addObject:@"A"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_B) { + [parts addObject:@"B"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_X) { + [parts addObject:@"X"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_Y) { + [parts addObject:@"Y"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_LEFT_SHOULDER) { + [parts addObject:@"LB"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_RIGHT_SHOULDER) { + [parts addObject:@"RB"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_BACK) { + [parts addObject:@"Back"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_START) { + [parts addObject:@"Start"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_LEFT_THUMB) { + [parts addObject:@"LS"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_RIGHT_THUMB) { + [parts addObject:@"RS"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_DPAD_UP) { + [parts addObject:@"D-Up"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_DPAD_DOWN) { + [parts addObject:@"D-Down"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_DPAD_LEFT) { + [parts addObject:@"D-Left"]; + } + if (buttons & xe::hid::X_INPUT_GAMEPAD_DPAD_RIGHT) { + [parts addObject:@"D-Right"]; + } +} + +NSString* TouchInteractionBehaviorOutputText( + const xe::hid::touch::IOSTouchInteractionBehavior& behavior) { + NSMutableArray* outputs = [NSMutableArray array]; + if (behavior.action != xe::hid::touch::IOSTouchAction::kNone) { + [outputs addObject:[NSString stringWithUTF8String:xe::hid::touch::IOSTouchActionDisplayName( + behavior.action)]]; + } + if (behavior.enables_relative_look) { + [outputs addObject:@"Look"]; + } + return outputs.count ? [outputs componentsJoinedByString:@" + "] : @"Unused"; +} + +xe::hid::touch::IOSTouchPoint ToInputPoint(CGPoint point) { + return xe::hid::touch::IOSTouchPoint{static_cast(point.x), static_cast(point.y)}; +} + +CGPoint ToCGPoint(xe::hid::touch::IOSTouchPoint point) { return CGPointMake(point.x, point.y); } + +xe::hid::touch::IOSTouchComboSubzone ToInputComboSubzone(TouchCaptureState::ComboSubzone subzone) { + switch (subzone) { + case TouchCaptureState::ComboSubzone::kStick: + return xe::hid::touch::IOSTouchComboSubzone::kStick; + case TouchCaptureState::ComboSubzone::kDpadUp: + return xe::hid::touch::IOSTouchComboSubzone::kDpadUp; + case TouchCaptureState::ComboSubzone::kDpadDown: + return xe::hid::touch::IOSTouchComboSubzone::kDpadDown; + case TouchCaptureState::ComboSubzone::kDpadLeft: + return xe::hid::touch::IOSTouchComboSubzone::kDpadLeft; + case TouchCaptureState::ComboSubzone::kDpadRight: + return xe::hid::touch::IOSTouchComboSubzone::kDpadRight; + case TouchCaptureState::ComboSubzone::kNone: + default: + return xe::hid::touch::IOSTouchComboSubzone::kNone; + } +} + +TouchCaptureState::ComboSubzone ToOverlayComboSubzone( + xe::hid::touch::IOSTouchComboSubzone subzone) { + switch (subzone) { + case xe::hid::touch::IOSTouchComboSubzone::kStick: + return TouchCaptureState::ComboSubzone::kStick; + case xe::hid::touch::IOSTouchComboSubzone::kDpadUp: + return TouchCaptureState::ComboSubzone::kDpadUp; + case xe::hid::touch::IOSTouchComboSubzone::kDpadDown: + return TouchCaptureState::ComboSubzone::kDpadDown; + case xe::hid::touch::IOSTouchComboSubzone::kDpadLeft: + return TouchCaptureState::ComboSubzone::kDpadLeft; + case xe::hid::touch::IOSTouchComboSubzone::kDpadRight: + return TouchCaptureState::ComboSubzone::kDpadRight; + case xe::hid::touch::IOSTouchComboSubzone::kNone: + default: + return TouchCaptureState::ComboSubzone::kNone; + } +} + +xe::hid::touch::IOSTouchInputCapture ToInputCapture(const TouchCaptureState& capture) { + xe::hid::touch::IOSTouchInputCapture input_capture; + input_capture.anchor_point = ToInputPoint(capture.anchor_point); + input_capture.current_point = ToInputPoint(capture.current_point); + input_capture.began_time = capture.began_time; + input_capture.secondary_behavior_triggered = capture.secondary_behavior_triggered; + input_capture.combo_subzone = ToInputComboSubzone(capture.combo_subzone); + return input_capture; +} + +} // namespace + +const float kEditCanonicalControlSizes[9] = { + 0.08f, 0.10f, 0.12f, 0.14f, 0.16f, 0.18f, 0.20f, 0.24f, 0.28f, +}; + +const float kLookZoneScaleChoices[8] = { + 0.25f, 0.50f, 0.75f, 1.00f, 1.50f, 2.00f, 3.00f, 4.00f, +}; + +const float kRelativeLookScaleChoices[8] = { + 0.50f, 0.66f, 0.80f, 0.92f, 1.05f, 1.25f, 1.50f, 2.00f, +}; + +const xe::hid::touch::IOSTouchControlShape kEditShapeChoices[2] = { + xe::hid::touch::IOSTouchControlShape::kCircle, + xe::hid::touch::IOSTouchControlShape::kRoundedRect, +}; + +float TouchLookPointsPerFullScale() { + return std::clamp(static_cast(::cvars::ios_touch_look_points_per_full_scale), 1.0f, 64.0f); +} + +float TouchLookVerticalScale() { + return std::clamp(static_cast(::cvars::ios_touch_look_vertical_scale), 0.25f, 4.0f); +} + +float TouchLookHoldSeconds() { + return std::clamp(static_cast(::cvars::ios_touch_look_hold_seconds), 0.016f, 0.25f); +} + +float TouchButtonTapHoldSeconds() { + return std::clamp(static_cast(::cvars::ios_touch_button_tap_hold_seconds), 0.016f, 0.25f); +} + +CGPoint ClampLookVector(CGPoint value) { + return ToCGPoint(xe::hid::touch::ClampTouchLookVector(ToInputPoint(value))); +} + +CGPoint SwipeLookVectorForDelta(CGPoint delta, float look_scale) { + return ToCGPoint(xe::hid::touch::TouchSwipeLookVectorForDelta( + ToInputPoint(delta), look_scale, TouchLookPointsPerFullScale(), TouchLookVerticalScale())); +} + +std::array EditChromeDockCandidateFrames( + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, CGFloat chrome_margin, + CGFloat chrome_width, CGFloat chrome_height) { + const CGFloat min_x = safe_area.origin_x + chrome_margin; + const CGFloat max_x = + MAX(min_x, safe_area.origin_x + safe_area.width - chrome_width - chrome_margin); + const CGFloat min_y = safe_area.origin_y + chrome_margin; + const CGFloat max_y = + MAX(min_y, safe_area.origin_y + safe_area.height - chrome_height - chrome_margin); + const CGFloat center_x = + std::clamp(safe_area.origin_x + (safe_area.width - chrome_width) * 0.5f, min_x, max_x); + const CGFloat center_y = + std::clamp(safe_area.origin_y + (safe_area.height - chrome_height) * 0.5f, min_y, max_y); + return { + CGRectMake(min_x, min_y, chrome_width, chrome_height), + CGRectMake(max_x, min_y, chrome_width, chrome_height), + CGRectMake(min_x, max_y, chrome_width, chrome_height), + CGRectMake(max_x, max_y, chrome_width, chrome_height), + CGRectMake(center_x, min_y, chrome_width, chrome_height), + CGRectMake(center_x, max_y, chrome_width, chrome_height), + CGRectMake(min_x, center_y, chrome_width, chrome_height), + CGRectMake(max_x, center_y, chrome_width, chrome_height), + }; +} + +TouchCaptureState::ComboSubzone TouchComboSubzoneForPoint( + const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& resolved_frame, CGPoint point) { + return ToOverlayComboSubzone( + xe::hid::touch::ResolveTouchComboSubzone(control, resolved_frame, ToInputPoint(point))); +} + +bool TouchControlContainsPoint(const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& resolved_frame, CGPoint point) { + return xe::hid::touch::TouchControlContainsPoint(control, resolved_frame, ToInputPoint(point)); +} + +TouchInteractionBehaviorState ResolveTouchInteractionBehaviorState( + const xe::hid::touch::IOSTouchInteractionBehavior& behavior, const TouchCaptureState& capture, + CFTimeInterval current_time) { + return xe::hid::touch::ResolveTouchInteractionBehaviorState(behavior, ToInputCapture(capture), + current_time); +} + +NSString* TouchInteractionBehaviorSummaryText( + const xe::hid::touch::IOSTouchInteractionBehavior& behavior) { + if (behavior.trigger == xe::hid::touch::IOSTouchInteractionTrigger::kNone) { + return @"Behavior: Off"; + } + NSString* trigger = [NSString + stringWithUTF8String:xe::hid::touch::IOSTouchInteractionTriggerDisplayName(behavior.trigger)]; + NSString* outputs = TouchInteractionBehaviorOutputText(behavior); + return [NSString stringWithFormat:@"Behavior: %@ -> %@", trigger, outputs]; +} + +CGPoint MoveStickUnitVectorForCapture(const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& frame, + const TouchCaptureState& capture) { + return ToCGPoint( + xe::hid::touch::MoveStickUnitVectorForCapture(control, frame, ToInputCapture(capture))); +} + +bool MoveStickCaptureQualifiesForDoubleTapForward( + const xe::hid::touch::IOSTouchControlDefinition& control, + const xe::hid::touch::IOSTouchRect& frame, const TouchCaptureState& capture, + CFTimeInterval current_time) { + return xe::hid::touch::MoveStickCaptureQualifiesForDoubleTapForward( + control, frame, ToInputCapture(capture), current_time); +} + +NSString* TouchButtonMaskPreviewText(uint16_t buttons) { + NSMutableArray* parts = [NSMutableArray array]; + AppendTouchButtonMaskNames(buttons, parts); + return parts.count ? [parts componentsJoinedByString:@" + "] : @"0"; +} + +NSString* TouchControlShapeDisplayText(xe::hid::touch::IOSTouchControlShape shape) { + switch (shape) { + case xe::hid::touch::IOSTouchControlShape::kCircle: + return @"Round"; + case xe::hid::touch::IOSTouchControlShape::kRoundedRect: + return @"Rounded"; + } + return @"Round"; +} + +CGFloat TouchEditPreviewHeightForLabel(UILabel* label, CGFloat width) { + if (label.hidden || width <= 0.0f) { + return 0.0f; + } + CGSize measured = [label sizeThatFits:CGSizeMake(width, CGFLOAT_MAX)]; + return MAX(34.0f, ceil(measured.height) + 12.0f); +} + +CGFloat LayoutVisibleButtonsRow(CGFloat y, CGFloat x, CGFloat width, CGFloat height, CGFloat gap, + NSArray* buttons) { + NSMutableArray* visible_buttons = [NSMutableArray arrayWithCapacity:buttons.count]; + for (UIButton* button in buttons) { + if (button.hidden) { + button.frame = CGRectZero; + continue; + } + [visible_buttons addObject:button]; + } + if (!visible_buttons.count) { + return y; + } + + const CGFloat available_width = width - gap * (visible_buttons.count - 1); + const CGFloat base_width = floor(available_width / visible_buttons.count); + CGFloat current_x = x; + for (NSUInteger button_index = 0; button_index < visible_buttons.count; ++button_index) { + UIButton* button = [visible_buttons objectAtIndex:button_index]; + const CGFloat button_width = + button_index + 1 == visible_buttons.count ? x + width - current_x : base_width; + button.frame = CGRectMake(current_x, y, button_width, height); + current_x += button_width + gap; + } + return y + height + gap; +} + +} // namespace xe::ui::ios::touch_overlay diff --git a/src/xenia/ui/ios/touch/touch_controls_overlay_ios.h b/src/xenia/ui/ios/touch/touch_controls_overlay_ios.h new file mode 100644 index 000000000..1b925f3e5 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_controls_overlay_ios.h @@ -0,0 +1,58 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_TOUCH_CONTROLS_OVERLAY_IOS_H_ +#define XENIA_UI_TOUCH_CONTROLS_OVERLAY_IOS_H_ + +namespace xe { +namespace hid { +namespace touch { +class IOSTouchRuntimeModel; +} // namespace touch +} // namespace hid +} // namespace xe + +#ifdef __OBJC__ + +#import + +#import "xenia/ui/ios/touch/touch_layout_library_view_ios.h" + +@interface XeniaTouchControlsOverlayView : UIView + +@property(nonatomic, copy) void (^pauseHandler)(void); +@property(nonatomic, copy) void (^doneEditingHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryLoadHandler)(NSString* localID); +@property(nonatomic, copy) void (^layoutLibrarySaveCopyHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryRenameHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryDeleteHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryImportHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryExportHandler)(void); +@property(nonatomic, copy) void (^layoutLibraryResetHandler)(void); + +- (instancetype)initWithRuntimeModel:(xe::hid::touch::IOSTouchRuntimeModel*)runtime_model + NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithCoder:(NSCoder*)coder NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +- (BOOL)isEditingControlsEnabled; +- (BOOL)isShowingLayoutLibrary; +- (void)setEditingControlsEnabled:(BOOL)enabled animated:(BOOL)animated; +- (void)refreshLayoutModel; +- (void)setGameplayOverlayVisible:(BOOL)visible animated:(BOOL)animated; +- (void)showLayoutLibraryWithItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID; +- (void)hideLayoutLibrary; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_TOUCH_CONTROLS_OVERLAY_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_controls_overlay_ios.mm b/src/xenia/ui/ios/touch/touch_controls_overlay_ios.mm new file mode 100644 index 000000000..1a2554aeb --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_controls_overlay_ios.mm @@ -0,0 +1,3469 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_controls_overlay_ios.h" + +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/hid/touch/touch_layout_editor.h" +#include "xenia/hid/touch/touch_layout_ios.h" +#import "xenia/ui/ios/shared/ios_theme_controls.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" +#import "xenia/ui/ios/touch/touch_control_shell_view_ios.h" +#import "xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h" +#import "xenia/ui/ios/touch/touch_layout_library_view_ios.h" +#import "xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h" +#include "xenia/ui/ios/touch/touch_overlay_edit_history_ios.h" +#include "xenia/ui/ios/touch/touch_overlay_geometry_ios.h" +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +DECLARE_bool(ios_touch_haptics); + +namespace { + +using xe::ui::CGRectFromTouchRect; +using xe::ui::ClampNormalizedControlFrame; +using xe::ui::NormalizedControlFrameFromResolvedFrame; +using xe::ui::ResolveNormalizedControlFrame; +using xe::ui::SnapTouchEditResolvedFrame; +using xe::ui::TouchControlDeckSpaceForView; +using xe::ui::TouchControlSizeSpaceForControlType; +using xe::ui::TouchEditGestureMode; +using xe::ui::TouchEditSnapOptions; +using xe::ui::TouchEditSnapResult; +using xe::ui::TouchOverlayIsPortraitForView; +using xe::ui::TouchSafeAreaSpaceForView; +using xe::ui::XeniaTouchConfiguredControlLabelText; +using xe::ui::XeniaTouchVisibleControlLabelText; + +using namespace xe::ui::ios::touch_overlay; + +} // namespace + +@interface XeniaTouchControlsOverlayView () + +- (void)finalizeTouches:(NSSet*)touches cancelled:(BOOL)cancelled; + +@end + +@implementation XeniaTouchControlsOverlayView { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model_; + NSMutableArray* control_views_; + UIButton* pause_button_; + UIView* edit_grid_overlay_; + CAShapeLayer* edit_grid_dots_layer_; + UIView* edit_snap_guides_overlay_; + CAShapeLayer* edit_snap_guides_layer_; + UIView* edit_safe_area_guide_; + XeniaTouchOverlayEditChromeIOS* edit_chrome_; + UIView* edit_resize_handle_; + UIView* move_knob_; + CADisplayLink* display_link_; + std::vector resolved_control_frames_; + std::vector conflicting_control_indices_; + std::vector visually_active_control_indices_; + std::vector recent_action_press_times_; + std::vector recent_secondary_press_times_; + std::vector recent_secondary_candidate_times_; + std::vector recent_look_vectors_; + std::vector recent_look_motion_times_; + std::vector active_captures_; + std::vector active_snap_vertical_guides_; + std::vector active_snap_horizontal_guides_; + xe::hid::touch::IOSTouchResolvedState last_published_state_; + uint32_t next_packet_number_; + BOOL gameplay_overlay_active_; + BOOL editing_controls_enabled_; + BOOL edit_showing_layout_library_; + NSUInteger selected_control_index_; + NSUInteger move_control_index_; + NSUInteger look_control_index_; + NSUInteger pause_control_index_; + BOOL edit_grid_enabled_; + TouchOverlayEditHistoryIOS edit_history_; + BOOL edit_chrome_minimized_; + BOOL edit_chrome_drag_active_; + UITouch* edit_chrome_drag_touch_; + CGRect edit_chrome_drag_frame_; + CGPoint edit_chrome_drag_touch_offset_; + NSInteger edit_chrome_dock_index_; + BOOL edit_pinch_active_; + NSUInteger edit_pinch_control_index_; + UITouch* edit_pinch_touch_a_; + UITouch* edit_pinch_touch_b_; + CGFloat edit_pinch_initial_distance_; + xe::hid::touch::IOSTouchRect edit_pinch_initial_frame_; + UIImpactFeedbackGenerator* haptic_press_; // medium impact on action press + UIImpactFeedbackGenerator* haptic_press_light_; // light impact on stick engage + UIImpactFeedbackGenerator* haptic_snap_; // rigid impact on snap engage + UISelectionFeedbackGenerator* haptic_selection_; + BOOL snap_guides_were_visible_; + // Tracks the orientation we last laid out in. layoutSubviews compares this + // against the current bounds-derived orientation so it can refresh the + // edit chrome chip when the device rotates without forcing a refresh on + // every layout pass. + BOOL last_layout_was_portrait_; + BOOL last_layout_orientation_known_; + UIView* tooltip_view_; + UILabel* tooltip_label_; + UILongPressGestureRecognizer* tooltip_long_press_; +} + +@synthesize pauseHandler = pauseHandler_; +@synthesize doneEditingHandler = doneEditingHandler_; +@synthesize layoutLibraryHandler = layoutLibraryHandler_; +@synthesize layoutLibraryLoadHandler = layoutLibraryLoadHandler_; +@synthesize layoutLibrarySaveCopyHandler = layoutLibrarySaveCopyHandler_; +@synthesize layoutLibraryRenameHandler = layoutLibraryRenameHandler_; +@synthesize layoutLibraryDeleteHandler = layoutLibraryDeleteHandler_; +@synthesize layoutLibraryImportHandler = layoutLibraryImportHandler_; +@synthesize layoutLibraryExportHandler = layoutLibraryExportHandler_; +@synthesize layoutLibraryResetHandler = layoutLibraryResetHandler_; + +- (void)createDisplayLinkIfNeeded { + if (display_link_) { + return; + } + display_link_ = [[CADisplayLink displayLinkWithTarget:self + selector:@selector(displayLinkFired:)] retain]; + display_link_.paused = YES; + [display_link_ addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +} + +- (instancetype)initWithRuntimeModel:(xe::hid::touch::IOSTouchRuntimeModel*)runtime_model { + if (!(self = [super initWithFrame:CGRectZero])) { + return nil; + } + + // The touch overlay sits on top of running gameplay; force dark so pause + // glyphs, edit chrome, tooltips, and snap guides stay readable. + self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; + + runtime_model_ = runtime_model; + control_views_ = [[NSMutableArray alloc] init]; + move_control_index_ = NSNotFound; + look_control_index_ = NSNotFound; + pause_control_index_ = NSNotFound; + next_packet_number_ = 1; + gameplay_overlay_active_ = NO; + editing_controls_enabled_ = NO; + edit_showing_layout_library_ = NO; + selected_control_index_ = NSNotFound; + edit_chrome_minimized_ = NO; + edit_chrome_drag_active_ = NO; + edit_chrome_drag_touch_ = nil; + edit_chrome_drag_frame_ = CGRectZero; + edit_chrome_drag_touch_offset_ = CGPointZero; + edit_chrome_dock_index_ = kEditChromeDockAuto; + last_layout_was_portrait_ = NO; + last_layout_orientation_known_ = NO; + + pause_button_ = [[UIButton buttonWithType:UIButtonTypeCustom] retain]; + pause_button_.backgroundColor = [UIColor clearColor]; + pause_button_.hidden = YES; + pause_button_.accessibilityLabel = @"Pause game"; + pause_button_.accessibilityTraits = UIAccessibilityTraitButton; + [pause_button_ addTarget:self + action:@selector(pauseButtonPressed:) + forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:pause_button_]; + + edit_grid_overlay_ = [[UIView alloc] initWithFrame:CGRectZero]; + edit_grid_overlay_.hidden = YES; + edit_grid_overlay_.backgroundColor = [UIColor clearColor]; + edit_grid_overlay_.userInteractionEnabled = NO; + edit_grid_dots_layer_ = [[CAShapeLayer alloc] init]; + edit_grid_dots_layer_.fillColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]].CGColor; + edit_grid_dots_layer_.strokeColor = nil; + [edit_grid_overlay_.layer addSublayer:edit_grid_dots_layer_]; + [self addSubview:edit_grid_overlay_]; + + edit_snap_guides_overlay_ = [[UIView alloc] initWithFrame:CGRectZero]; + edit_snap_guides_overlay_.hidden = YES; + edit_snap_guides_overlay_.backgroundColor = [UIColor clearColor]; + edit_snap_guides_overlay_.userInteractionEnabled = NO; + edit_snap_guides_layer_ = [[CAShapeLayer alloc] init]; + edit_snap_guides_layer_.hidden = YES; + edit_snap_guides_layer_.fillColor = nil; + edit_snap_guides_layer_.strokeColor = + [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.88].CGColor; + edit_snap_guides_layer_.lineWidth = kEditSnapGuideLineWidth; + edit_snap_guides_layer_.lineDashPattern = @[ @5.0f, @7.0f ]; + edit_snap_guides_layer_.lineCap = kCALineCapRound; + [edit_snap_guides_overlay_.layer addSublayer:edit_snap_guides_layer_]; + [self addSubview:edit_snap_guides_overlay_]; + + edit_safe_area_guide_ = [[UIView alloc] initWithFrame:CGRectZero]; + edit_safe_area_guide_.hidden = YES; + edit_safe_area_guide_.backgroundColor = [UIColor clearColor]; + edit_safe_area_guide_.layer.borderWidth = 1.0; + edit_safe_area_guide_.layer.borderColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacityStrong]].CGColor; + // Matches xe_apply_floating_window_chrome's 20pt — visually pairs the safe + // area indicator with the floating editor chrome it sits behind. + edit_safe_area_guide_.layer.cornerRadius = 20.0; + edit_safe_area_guide_.userInteractionEnabled = NO; + [self addSubview:edit_safe_area_guide_]; + + edit_chrome_ = [[XeniaTouchOverlayEditChromeIOS alloc] initWithFrame:CGRectZero]; + edit_chrome_.delegate = self; + [self addSubview:edit_chrome_]; + + edit_resize_handle_ = [[UIView alloc] initWithFrame:CGRectZero]; + edit_resize_handle_.hidden = YES; + edit_resize_handle_.backgroundColor = + [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.92]; + // Resize handle is a 28pt-diameter visual element; 14pt = 28/2 keeps the + // circle a true circle even when the handle is offset off-corner. + edit_resize_handle_.layer.cornerRadius = 14.0; + edit_resize_handle_.layer.borderWidth = 1.5; + edit_resize_handle_.layer.borderColor = + [[UIColor blackColor] colorWithAlphaComponent:0.35].CGColor; + [self addSubview:edit_resize_handle_]; + + move_knob_ = [[UIView alloc] initWithFrame:CGRectZero]; + move_knob_.hidden = YES; + move_knob_.backgroundColor = + [[UIColor whiteColor] colorWithAlphaComponent:0.18]; + move_knob_.layer.borderWidth = 1.0; + move_knob_.layer.borderColor = + [[UIColor whiteColor] colorWithAlphaComponent:0.72].CGColor; + [self addSubview:move_knob_]; + + [self createDisplayLinkIfNeeded]; + + self.backgroundColor = [UIColor clearColor]; + self.opaque = NO; + self.alpha = 0.0; + self.hidden = YES; + self.userInteractionEnabled = YES; + self.multipleTouchEnabled = YES; + edit_grid_enabled_ = NO; + + // Pre-construct haptic generators so the first press does not pay the + // generator-init latency. The system frees them when nothing has used them + // recently; -prepare on each fire keeps them warm during gameplay. + haptic_press_ = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + haptic_press_light_ = + [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; + haptic_snap_ = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleRigid]; + haptic_selection_ = [[UISelectionFeedbackGenerator alloc] init]; + + // Long-press tooltip: when the user holds a finger on a control in edit + // mode for ~0.4s, show a bubble describing what the control is bound to. + // Helps with discoverability without forcing the user into the binding menu. + // Use the unified floating-window chrome so all of the iOS chrome shares the + // same look. + tooltip_view_ = [[UIView alloc] initWithFrame:CGRectZero]; + xe_apply_floating_window_chrome(tooltip_view_); + tooltip_view_.alpha = 0.0f; + tooltip_view_.userInteractionEnabled = NO; + tooltip_view_.hidden = YES; + [self addSubview:tooltip_view_]; + + tooltip_label_ = [[UILabel alloc] initWithFrame:CGRectZero]; + tooltip_label_.numberOfLines = 0; + tooltip_label_.textColor = [UIColor whiteColor]; + xe_apply_label_font(tooltip_label_, UIFontTextStyleCaption1, 12.0, + UIFontWeightMedium); + tooltip_label_.textAlignment = NSTextAlignmentLeft; + tooltip_label_.backgroundColor = [UIColor clearColor]; + [tooltip_view_ addSubview:tooltip_label_]; + + tooltip_long_press_ = + [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(tooltipLongPressTriggered:)]; + tooltip_long_press_.minimumPressDuration = 0.40; + tooltip_long_press_.cancelsTouchesInView = NO; + tooltip_long_press_.delaysTouchesBegan = NO; + tooltip_long_press_.delaysTouchesEnded = NO; + [self addGestureRecognizer:tooltip_long_press_]; + + [self refreshLayoutModel]; + return self; +} + +- (void)playPressHaptic { + if (!cvars::ios_touch_haptics) { + return; + } + [haptic_press_ impactOccurred]; + [haptic_press_ prepare]; +} + +- (void)playLightPressHaptic { + if (!cvars::ios_touch_haptics) { + return; + } + [haptic_press_light_ impactOccurred]; + [haptic_press_light_ prepare]; +} + +- (void)playSnapHaptic { + if (!cvars::ios_touch_haptics) { + return; + } + [haptic_snap_ impactOccurred]; + [haptic_snap_ prepare]; +} + +- (void)playSelectionHaptic { + if (!cvars::ios_touch_haptics) { + return; + } + [haptic_selection_ selectionChanged]; + [haptic_selection_ prepare]; +} + +#pragma mark - Hardware keyboard shortcuts (edit mode) + +- (BOOL)canBecomeFirstResponder { + return editing_controls_enabled_; +} + +- (NSArray*)keyCommands { + if (!editing_controls_enabled_) { + return @[]; + } + UIKeyCommand* undo = [UIKeyCommand keyCommandWithInput:@"z" + modifierFlags:UIKeyModifierCommand + action:@selector(keyCommandUndo:)]; + undo.discoverabilityTitle = @"Undo edit"; + UIKeyCommand* redo = [UIKeyCommand keyCommandWithInput:@"z" + modifierFlags:UIKeyModifierCommand | UIKeyModifierShift + action:@selector(keyCommandRedo:)]; + redo.discoverabilityTitle = @"Redo edit"; + UIKeyCommand* duplicate = [UIKeyCommand keyCommandWithInput:@"d" + modifierFlags:UIKeyModifierCommand + action:@selector(keyCommandDuplicate:)]; + duplicate.discoverabilityTitle = @"Duplicate selected control"; + UIKeyCommand* mirror = [UIKeyCommand keyCommandWithInput:@"m" + modifierFlags:UIKeyModifierCommand + action:@selector(keyCommandMirror:)]; + mirror.discoverabilityTitle = @"Mirror selected control horizontally"; + UIKeyCommand* delete_command = [UIKeyCommand keyCommandWithInput:@"\b" + modifierFlags:UIKeyModifierCommand + action:@selector(keyCommandDelete:)]; + delete_command.discoverabilityTitle = @"Delete selected control"; + UIKeyCommand* done = [UIKeyCommand keyCommandWithInput:UIKeyInputEscape + modifierFlags:0 + action:@selector(keyCommandDone:)]; + done.discoverabilityTitle = @"Exit edit mode"; + return @[ undo, redo, duplicate, mirror, delete_command, done ]; +} + +- (void)keyCommandUndo:(UIKeyCommand*)__unused command { + [self undoEditLayoutChange]; +} + +- (void)keyCommandRedo:(UIKeyCommand*)__unused command { + [self redoEditLayoutChange]; +} + +- (void)keyCommandDuplicate:(UIKeyCommand*)__unused command { + [self duplicateSelectedControl]; +} + +- (void)keyCommandMirror:(UIKeyCommand*)__unused command { + [self mirrorSelectedControlHorizontally]; +} + +- (void)keyCommandDelete:(UIKeyCommand*)__unused command { + [self deleteSelectedControl]; +} + +- (void)keyCommandDone:(UIKeyCommand*)__unused command { + if (doneEditingHandler_) { + doneEditingHandler_(); + } +} + +#pragma mark - Long-press tooltip + +- (void)hideTooltip { + if (tooltip_view_.hidden) { + return; + } + [UIView animateWithDuration:0.10 + animations:^{ + tooltip_view_.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + tooltip_view_.hidden = YES; + }]; +} + +- (void)showTooltipForControlAtIndex:(NSUInteger)control_index nearPoint:(CGPoint)point { + if (!runtime_model_) { + return; + } + const auto& controls = runtime_model_->layout().controls; + if (control_index >= controls.size()) { + return; + } + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + + NSMutableString* text = [NSMutableString string]; + NSString* label = XeniaTouchVisibleControlLabelText(control, NO); + if (label.length) { + [text appendFormat:@"%@\n", label]; + } + [text appendFormat:@"Action: %s", xe::hid::touch::IOSTouchActionDisplayName(control.action)]; + if (control.secondary_behavior.trigger != xe::hid::touch::IOSTouchInteractionTrigger::kNone && + control.secondary_behavior.action != xe::hid::touch::IOSTouchAction::kNone) { + [text + appendFormat:@"\n2nd: %s · %s", + xe::hid::touch::IOSTouchInteractionTriggerDisplayName( + control.secondary_behavior.trigger), + xe::hid::touch::IOSTouchActionDisplayName(control.secondary_behavior.action)]; + } + [text + appendFormat:@"\nTint: %s", xe::hid::touch::IOSTouchTintStyleDisplayName(control.tint_style)]; + const bool tooltip_is_portrait = TouchOverlayIsPortraitForView(self); + const xe::hid::touch::IOSTouchRect& tooltip_frame = + xe::hid::touch::ActiveControlFrameForOrientation(control, tooltip_is_portrait); + [text appendFormat:@"\nFrame: %.2f, %.2f · %.2f × %.2f", tooltip_frame.x, tooltip_frame.y, + tooltip_frame.width, tooltip_frame.height]; + + tooltip_label_.text = text; + const CGSize max_size = CGSizeMake(220.0f, 1000.0f); + CGSize content_size = [tooltip_label_ sizeThatFits:max_size]; + const CGFloat tooltip_padding_x = 12.0f; + const CGFloat tooltip_padding_y = 10.0f; + const CGFloat tooltip_width = ceilf(content_size.width) + tooltip_padding_x * 2.0f; + const CGFloat tooltip_height = ceilf(content_size.height) + tooltip_padding_y * 2.0f; + + // Position above the press point if there's room; otherwise below. + CGFloat origin_x = std::clamp(point.x - tooltip_width * 0.5, 8.0, + CGRectGetWidth(self.bounds) - tooltip_width - 8.0); + CGFloat origin_y = point.y - tooltip_height - 18.0; + if (origin_y < self.safeAreaInsets.top + 8.0) { + origin_y = point.y + 18.0; + } + origin_y = std::clamp( + origin_y, self.safeAreaInsets.top + 8.0, + CGRectGetHeight(self.bounds) - tooltip_height - self.safeAreaInsets.bottom - 8.0); + + tooltip_view_.frame = CGRectMake(origin_x, origin_y, tooltip_width, tooltip_height); + tooltip_label_.frame = + CGRectMake(tooltip_padding_x, tooltip_padding_y, content_size.width, content_size.height); + + [self bringSubviewToFront:tooltip_view_]; + if (tooltip_view_.hidden) { + tooltip_view_.alpha = 0.0; + tooltip_view_.hidden = NO; + } + [UIView animateWithDuration:0.12 + animations:^{ + tooltip_view_.alpha = 1.0; + }]; +} + +- (void)tooltipLongPressTriggered:(UILongPressGestureRecognizer*)recognizer { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + if (recognizer.state == UIGestureRecognizerStateBegan) { + const CGPoint point = [recognizer locationInView:self]; + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + NSInteger best_index = -1; + uint8_t best_priority = 0; + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + if (control_index >= resolved_control_frames_.size()) { + continue; + } + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + if (!TouchControlContainsPoint(control, resolved_control_frames_[control_index], point)) { + continue; + } + if (best_index < 0 || control.capture_priority > best_priority) { + best_index = static_cast(control_index); + best_priority = control.capture_priority; + } + } + if (best_index >= 0) { + [self showTooltipForControlAtIndex:static_cast(best_index) nearPoint:point]; + } + } else if (recognizer.state == UIGestureRecognizerStateEnded || + recognizer.state == UIGestureRecognizerStateCancelled || + recognizer.state == UIGestureRecognizerStateFailed) { + [self hideTooltip]; + } +} + +- (void)willMoveToWindow:(UIWindow*)new_window { + if (!new_window && display_link_) { + [display_link_ invalidate]; + [display_link_ release]; + display_link_ = nil; + } else if (new_window && !display_link_) { + [self createDisplayLinkIfNeeded]; + } + [super willMoveToWindow:new_window]; +} + +- (void)dealloc { + [display_link_ invalidate]; + [display_link_ release]; + [haptic_press_ release]; + [haptic_press_light_ release]; + [haptic_snap_ release]; + [haptic_selection_ release]; + [tooltip_long_press_ release]; + [tooltip_label_ release]; + [tooltip_view_ release]; + [layoutLibraryResetHandler_ release]; + [layoutLibraryExportHandler_ release]; + [layoutLibraryImportHandler_ release]; + [layoutLibraryDeleteHandler_ release]; + [layoutLibraryRenameHandler_ release]; + [layoutLibrarySaveCopyHandler_ release]; + [layoutLibraryLoadHandler_ release]; + [layoutLibraryHandler_ release]; + [doneEditingHandler_ release]; + [pauseHandler_ release]; + [pause_button_ release]; + [edit_snap_guides_layer_ release]; + [edit_snap_guides_overlay_ release]; + [edit_grid_dots_layer_ release]; + [edit_grid_overlay_ release]; + [edit_resize_handle_ release]; + edit_chrome_.delegate = nil; + [edit_chrome_ release]; + [edit_safe_area_guide_ release]; + [move_knob_ release]; + [control_views_ release]; + [super dealloc]; +} + +- (BOOL)isShowingLayoutLibrary { + return edit_showing_layout_library_; +} + +- (void)showLayoutLibraryWithItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID { + [edit_chrome_ setLayoutLibraryItems:items currentLayoutLocalID:currentLayoutLocalID]; + edit_showing_layout_library_ = YES; + edit_chrome_minimized_ = NO; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; +} + +- (void)hideLayoutLibrary { + edit_showing_layout_library_ = NO; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; +} + +- (std::string)selectedControlIdentifier { + if (!runtime_model_ || selected_control_index_ == NSNotFound) { + return std::string(); + } + const auto& controls = runtime_model_->layout().controls; + if (selected_control_index_ >= controls.size()) { + return std::string(); + } + return controls[selected_control_index_].identifier; +} + +- (void)resetEditLayoutHistory { + edit_history_.Reset(); +} + +- (void)seedEditLayoutHistoryIfNeeded { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + edit_history_.SeedIfNeeded(runtime_model_->layout(), [self selectedControlIdentifier]); +} + +- (BOOL)canUndoEditLayoutChange { + return edit_history_.CanUndo(); +} + +- (BOOL)canRedoEditLayoutChange { + return edit_history_.CanRedo(); +} + +- (void)applyEditLayoutHistoryState:(const xe::hid::touch::IOSTouchLayoutModel&)layout + selectingIdentifier:(const std::string&)preferred_identifier { + if (!runtime_model_) { + return; + } + + edit_history_.CancelChange(); + [self clearEditSnapGuides]; + runtime_model_->SetLayout(layout); + [self refreshLayoutModel]; + if (!preferred_identifier.empty()) { + [self selectControlWithIdentifier:preferred_identifier]; + } + [self publishResolvedState]; +} + +- (void)beginEditLayoutChangeIfNeeded { + if (!editing_controls_enabled_ || !runtime_model_ || edit_history_.IsChangeActive()) { + return; + } + edit_history_.BeginChange(runtime_model_->layout(), [self selectedControlIdentifier]); +} + +- (void)finishEditLayoutChangeIfNeeded { + if (!edit_history_.IsChangeActive() || !runtime_model_) { + return; + } + const BOOL no_active_gestures = active_captures_.empty() && !edit_pinch_active_; + if (!no_active_gestures) { + return; + } + if (edit_history_.FinishChange(runtime_model_->layout(), [self selectedControlIdentifier])) { + [self refreshEditChromeSelection]; + } +} + +- (void)undoEditLayoutChange { + if (![self canUndoEditLayoutChange] || !runtime_model_ || !active_captures_.empty() || + edit_pinch_active_) { + return; + } + xe::hid::touch::IOSTouchLayoutModel layout; + std::string preferred_identifier; + if (!edit_history_.Undo(&layout, &preferred_identifier)) { + return; + } + [self applyEditLayoutHistoryState:layout selectingIdentifier:preferred_identifier]; + [self refreshEditChromeSelection]; +} + +- (void)redoEditLayoutChange { + if (![self canRedoEditLayoutChange] || !runtime_model_ || !active_captures_.empty() || + edit_pinch_active_) { + return; + } + xe::hid::touch::IOSTouchLayoutModel layout; + std::string preferred_identifier; + if (!edit_history_.Redo(&layout, &preferred_identifier)) { + return; + } + [self applyEditLayoutHistoryState:layout selectingIdentifier:preferred_identifier]; + [self refreshEditChromeSelection]; +} + +- (void)refreshLayoutModel { + std::string selected_identifier; + if (runtime_model_) { + const auto& existing_controls = runtime_model_->layout().controls; + if (selected_control_index_ != NSNotFound && + selected_control_index_ < existing_controls.size()) { + selected_identifier = existing_controls[selected_control_index_].identifier; + } + } + [self resetInteractionState]; + for (UIView* control_view in control_views_) { + [control_view removeFromSuperview]; + } + [control_views_ removeAllObjects]; + resolved_control_frames_.clear(); + conflicting_control_indices_.clear(); + visually_active_control_indices_.clear(); + recent_action_press_times_.clear(); + recent_secondary_press_times_.clear(); + recent_secondary_candidate_times_.clear(); + recent_look_vectors_.clear(); + recent_look_motion_times_.clear(); + move_control_index_ = NSNotFound; + look_control_index_ = NSNotFound; + pause_control_index_ = NSNotFound; + selected_control_index_ = NSNotFound; + + if (!runtime_model_) { + pause_button_.hidden = YES; + move_knob_.hidden = YES; + return; + } + + const auto& controls = runtime_model_->layout().controls; + resolved_control_frames_.resize(controls.size()); + conflicting_control_indices_.assign(controls.size(), false); + visually_active_control_indices_.assign(controls.size(), 0); + recent_action_press_times_.assign(controls.size(), 0.0); + recent_secondary_press_times_.assign(controls.size(), 0.0); + recent_secondary_candidate_times_.assign(controls.size(), 0.0); + recent_look_vectors_.assign(controls.size(), CGPointZero); + recent_look_motion_times_.assign(controls.size(), 0.0); + NSUInteger control_index = 0; + for (const auto& control : controls) { + XeniaTouchControlShellView* shell_view = + [[XeniaTouchControlShellView alloc] initWithControl:control]; + [control_views_ addObject:shell_view]; + [self addSubview:shell_view]; + [shell_view release]; + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kMoveStick: + move_control_index_ = control_index; + break; + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + look_control_index_ = control_index; + break; + case xe::hid::touch::IOSTouchControlType::kPauseButton: + pause_control_index_ = control_index; + break; + case xe::hid::touch::IOSTouchControlType::kActionButton: + default: + break; + } + ++control_index; + } + + pause_button_.hidden = pause_control_index_ == NSNotFound; + move_knob_.hidden = YES; + edit_grid_overlay_.hidden = !editing_controls_enabled_ || !edit_grid_enabled_; + edit_snap_guides_overlay_.hidden = + !editing_controls_enabled_ || + (active_snap_vertical_guides_.empty() && active_snap_horizontal_guides_.empty()); + edit_safe_area_guide_.hidden = !editing_controls_enabled_; + edit_chrome_.hidden = !editing_controls_enabled_; + edit_resize_handle_.hidden = !editing_controls_enabled_; + [self bringSubviewToFront:edit_safe_area_guide_]; + [self bringSubviewToFront:edit_snap_guides_overlay_]; + [self bringSubviewToFront:edit_chrome_]; + [self bringSubviewToFront:edit_resize_handle_]; + [self bringSubviewToFront:move_knob_]; + [self bringSubviewToFront:pause_button_]; + + if (!selected_identifier.empty()) { + const NSUInteger control_count = + static_cast(runtime_model_->layout().controls.size()); + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + if (runtime_model_->layout().controls[control_index].identifier == selected_identifier) { + [self setSelectedControlIndex:control_index]; + break; + } + } + } + if (editing_controls_enabled_ && selected_control_index_ == NSNotFound && runtime_model_ && + !runtime_model_->layout().controls.empty()) { + [self setSelectedControlIndex:(move_control_index_ != NSNotFound ? move_control_index_ : 0)]; + } else { + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + } + [self setNeedsLayout]; +} + +- (BOOL)isControlIndexCaptured:(NSUInteger)control_index { + return std::any_of(active_captures_.begin(), active_captures_.end(), + [control_index](const TouchCaptureState& capture) { + return capture.control_index == control_index; + }); +} + +- (TouchOverlayEditChromeState)currentEditChromeState { + TouchOverlayEditChromeState state; + state.editing_enabled = editing_controls_enabled_; + state.showing_layout_library = edit_showing_layout_library_; + state.minimized = edit_chrome_minimized_; + state.grid_enabled = edit_grid_enabled_; + state.can_undo = [self canUndoEditLayoutChange]; + state.can_redo = [self canRedoEditLayoutChange]; + state.editing_portrait = TouchOverlayIsPortraitForView(self); + + if (!runtime_model_) { + return state; + } + + const auto& controls = runtime_model_->layout().controls; + state.control_count = controls.size(); + state.layout_contains_move_stick = + [self layoutContainsControlType:xe::hid::touch::IOSTouchControlType::kMoveStick]; + state.layout_contains_look_zone = + [self layoutContainsControlType:xe::hid::touch::IOSTouchControlType::kLookSwipeZone]; + state.layout_contains_pause_button = + [self layoutContainsControlType:xe::hid::touch::IOSTouchControlType::kPauseButton]; + if (selected_control_index_ != NSNotFound && selected_control_index_ < controls.size()) { + state.has_selected_control = true; + state.selected_control = controls[selected_control_index_]; + state.can_match_selected_size = + [self nearestSizeMatchControlIndexForSelectedControl] != NSNotFound; + } + return state; +} + +- (void)refreshEditChromeSelection { + [edit_chrome_ applyState:[self currentEditChromeState]]; + [self setNeedsLayout]; +} + +- (CGRect)selectedControlResizeHandleFrame { + if (!editing_controls_enabled_ || selected_control_index_ == NSNotFound || + selected_control_index_ >= resolved_control_frames_.size()) { + return CGRectZero; + } + + const xe::hid::touch::IOSTouchRect& frame = resolved_control_frames_[selected_control_index_]; + const CGFloat handle_size = 28.0f; + return CGRectMake(frame.x + frame.width - handle_size * 0.5f, + frame.y + frame.height - handle_size * 0.5f, handle_size, handle_size); +} + +- (void)refreshEditPreview { + [edit_chrome_ applyState:[self currentEditChromeState]]; + [self setNeedsLayout]; +} + +- (float)doubleTapWindowSecondsForControl: + (const xe::hid::touch::IOSTouchControlDefinition&)control { + return std::clamp(control.secondary_behavior.hold_seconds, 0.12f, 0.60f); +} + +- (void)triggerSecondaryBehaviorPulseForControlIndex:(NSUInteger)control_index + atTime:(CFTimeInterval)current_time { + if (!runtime_model_ || control_index >= recent_secondary_press_times_.size()) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + if (control_index >= controls.size() || + controls[control_index].secondary_behavior.action == xe::hid::touch::IOSTouchAction::kNone) { + return; + } + + recent_secondary_press_times_[control_index] = current_time; +} + +- (BOOL)hasPendingDoubleTapCandidateForControlIndex:(NSUInteger)control_index + atTime:(CFTimeInterval)current_time { + if (!runtime_model_ || control_index >= recent_secondary_candidate_times_.size()) { + return NO; + } + + const auto& controls = runtime_model_->layout().controls; + if (control_index >= controls.size()) { + return NO; + } + + const CFTimeInterval candidate_time = recent_secondary_candidate_times_[control_index]; + if (candidate_time <= 0.0) { + return NO; + } + + return (current_time - candidate_time) <= + [self doubleTapWindowSecondsForControl:controls[control_index]]; +} + +- (BOOL)consumeDoubleTapCandidateForControlIndex:(NSUInteger)control_index + atTime:(CFTimeInterval)current_time { + if (![self hasPendingDoubleTapCandidateForControlIndex:control_index atTime:current_time]) { + return NO; + } + + recent_secondary_candidate_times_[control_index] = 0.0; + [self triggerSecondaryBehaviorPulseForControlIndex:control_index atTime:current_time]; + return YES; +} + +- (void)storeDoubleTapCandidateForControlIndex:(NSUInteger)control_index + atTime:(CFTimeInterval)current_time { + if (control_index >= recent_secondary_candidate_times_.size()) { + return; + } + recent_secondary_candidate_times_[control_index] = current_time; +} + +- (void)setSelectedControlAction:(xe::hid::touch::IOSTouchAction)action { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.action == action) { + return; + } + if (!xe::hid::touch::IsSupportedIOSTouchPrimaryAction(control.type, action)) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + if (control.type == xe::hid::touch::IOSTouchControlType::kActionButton) { + // Action buttons need their mapped XInput button bits / triggers / + // hold-while-captured semantics reset and re-derived from the new action. + xe::hid::touch::ConfigureIOSTouchControlAction(action, &control); + } else { + // Move / Look / Pause types don't carry button-mapping data; just swap + // the action so the publish path routes the control's input to the new + // thumbstick (or pause behaviour) without disturbing the rest of the + // control definition. + control.action = action; + } + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)setSelectedControlLabelHidden:(BOOL)hidden { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.label_hidden == hidden) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.label_hidden = hidden; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)resetSelectedControlLabel { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (!xe::hid::touch::IOSTouchControlHasCustomLabel(control)) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::ResetIOSTouchControlLabel(&control); + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)setSelectedControlCustomLabelText:(NSString*)label_text { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + std::string next_label = label_text ? std::string(label_text.UTF8String) : std::string(); + const std::string prior_label = xe::hid::touch::IOSTouchConfiguredControlLabel(control); + const bool prior_has_custom_label = xe::hid::touch::IOSTouchControlHasCustomLabel(control); + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::SetIOSTouchControlCustomLabel(std::move(next_label), &control); + if (xe::hid::touch::IOSTouchConfiguredControlLabel(control) == prior_label && + xe::hid::touch::IOSTouchControlHasCustomLabel(control) == prior_has_custom_label) { + edit_history_.CancelChange(); + return; + } + + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (UIViewController*)labelEditPresenter { + UIResponder* responder = self; + while ((responder = responder.nextResponder)) { + if ([responder isKindOfClass:[UIViewController class]]) { + UIViewController* controller = (UIViewController*)responder; + while (controller.presentedViewController && + !controller.presentedViewController.isBeingDismissed) { + controller = controller.presentedViewController; + } + return controller; + } + } + + UIViewController* controller = self.window.rootViewController; + while (controller.presentedViewController && + !controller.presentedViewController.isBeingDismissed) { + controller = controller.presentedViewController; + } + return controller; +} + +- (void)presentLabelRenameAlert { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + UIViewController* presenter = [self labelEditPresenter]; + if (!presenter) { + return; + } + + const auto& control = controls[selected_control_index_]; + NSString* current_label = XeniaTouchConfiguredControlLabelText(control, NO); + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:@"Control Label" + message:@"Leave blank to use the default label." + preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.text = current_label; + text_field.placeholder = @"Label"; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + text_field.returnKeyType = UIReturnKeyDone; + }]; + + __unsafe_unretained XeniaTouchControlsOverlayView* unsafe_self = self; + __unsafe_unretained UIAlertController* unsafe_alert = alert; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + UIAlertAction* save_action = + [UIAlertAction actionWithTitle:@"Save" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action_handler) { + UITextField* text_field = unsafe_alert.textFields.firstObject; + [unsafe_self setSelectedControlCustomLabelText:text_field.text]; + }]; + [alert addAction:save_action]; + alert.preferredAction = save_action; + [presenter presentViewController:alert animated:YES completion:nil]; +} + +- (void)setSelectedControlBehaviorTrigger:(xe::hid::touch::IOSTouchInteractionTrigger)trigger { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + const bool supports_behavior = + control.type == xe::hid::touch::IOSTouchControlType::kActionButton || + control.type == xe::hid::touch::IOSTouchControlType::kMoveStick; + if (!supports_behavior || control.secondary_behavior.trigger == trigger) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.secondary_behavior.trigger = trigger; + control.secondary_behavior.hold_seconds = + xe::hid::touch::DefaultIOSTouchHoldSecondsForInteractionTrigger(trigger); + if (trigger == xe::hid::touch::IOSTouchInteractionTrigger::kNone) { + control.secondary_behavior.enables_relative_look = false; + } else if (trigger == xe::hid::touch::IOSTouchInteractionTrigger::kHoldDrag) { + control.secondary_behavior.enables_relative_look = true; + if (control.secondary_behavior.relative_look_scale <= 0.0f) { + control.secondary_behavior.relative_look_scale = 1.0f; + } + } else { + control.secondary_behavior.enables_relative_look = false; + } + if (control.type == xe::hid::touch::IOSTouchControlType::kMoveStick && + trigger == xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTapForward && + control.secondary_behavior.action == xe::hid::touch::IOSTouchAction::kNone) { + control.secondary_behavior.action = xe::hid::touch::IOSTouchAction::kLeftThumb; + } + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)setSelectedControlBehaviorAction:(xe::hid::touch::IOSTouchAction)action { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + const bool supports_behavior = + control.type == xe::hid::touch::IOSTouchControlType::kActionButton || + control.type == xe::hid::touch::IOSTouchControlType::kMoveStick; + if (!supports_behavior || control.secondary_behavior.action == action) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.secondary_behavior.action = action; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)setSelectedControlTintStyle:(xe::hid::touch::IOSTouchTintStyle)tint_style { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.tint_style == tint_style) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.tint_style = tint_style; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)setSelectedControlShape:(xe::hid::touch::IOSTouchControlShape)shape { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.type != xe::hid::touch::IOSTouchControlType::kActionButton || + control.shape == shape) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.shape = shape; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (BOOL)selectedControlSupportsLookScaleTuning { + if (!runtime_model_ || selected_control_index_ == NSNotFound) { + return NO; + } + const auto& controls = runtime_model_->layout().controls; + if (selected_control_index_ >= controls.size()) { + return NO; + } + const auto& control = controls[selected_control_index_]; + return control.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone || + control.enables_relative_look; +} + +- (float)selectedControlLookScale { + if (!runtime_model_ || selected_control_index_ == NSNotFound) { + return 1.0f; + } + const auto& controls = runtime_model_->layout().controls; + if (selected_control_index_ >= controls.size()) { + return 1.0f; + } + return std::clamp(controls[selected_control_index_].relative_look_scale, 0.25f, 4.0f); +} + +- (void)setSelectedControlLookScale:(float)look_scale { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (!(control.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone || + control.enables_relative_look)) { + return; + } + + const float clamped_scale = std::clamp(look_scale, 0.25f, 4.0f); + if (std::abs(control.relative_look_scale - clamped_scale) < 0.001f) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.relative_look_scale = clamped_scale; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (BOOL)layoutContainsControlType:(xe::hid::touch::IOSTouchControlType)type { + if (!runtime_model_) { + return NO; + } + const auto& controls = runtime_model_->layout().controls; + return std::any_of(controls.begin(), controls.end(), + [type](const xe::hid::touch::IOSTouchControlDefinition& control) { + return control.type == type; + }); +} + +- (void)addControlOfType:(xe::hid::touch::IOSTouchControlType)type { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + if (runtime_model_->layout().controls.size() >= xe::hid::touch::kMaxIOSTouchControls) { + return; + } + if (type == xe::hid::touch::IOSTouchControlType::kActionButton) { + [self addNewActionButton]; + return; + } + if ([self layoutContainsControlType:type]) { + return; + } + + [self clearEditSnapGuides]; + [self beginEditLayoutChangeIfNeeded]; + auto& layout = runtime_model_->mutable_layout(); + xe::hid::touch::IOSTouchControlDefinition control = + xe::hid::touch::CreateDefaultIOSTouchControlDefinition(type); + const std::string selected_identifier = control.identifier; + layout.controls.push_back(std::move(control)); + [self refreshLayoutModel]; + [self selectControlWithIdentifier:selected_identifier]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (NSUInteger)nearestSizeMatchControlIndexForSelectedControl { + if (!runtime_model_ || selected_control_index_ == NSNotFound || + selected_control_index_ >= resolved_control_frames_.size()) { + return NSNotFound; + } + + const auto& controls = runtime_model_->layout().controls; + if (selected_control_index_ >= controls.size()) { + return NSNotFound; + } + + const auto& selected_control = controls[selected_control_index_]; + if (selected_control.type != xe::hid::touch::IOSTouchControlType::kActionButton) { + return NSNotFound; + } + + const xe::hid::touch::IOSTouchRect& selected_frame = + resolved_control_frames_[selected_control_index_]; + if (selected_frame.width <= 0.0f || selected_frame.height <= 0.0f) { + return NSNotFound; + } + const CGFloat selected_center_x = selected_frame.x + selected_frame.width * 0.5f; + const CGFloat selected_center_y = selected_frame.y + selected_frame.height * 0.5f; + + auto find_best_match = [&](BOOL same_type_only) { + NSUInteger best_index = NSNotFound; + CGFloat best_distance = CGFLOAT_MAX; + for (NSUInteger control_index = 0; + control_index < MIN(static_cast(controls.size()), + static_cast(resolved_control_frames_.size())); + ++control_index) { + if (control_index == selected_control_index_) { + continue; + } + const auto& candidate = controls[control_index]; + if (candidate.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + continue; + } + if (same_type_only && candidate.type != selected_control.type) { + continue; + } + const xe::hid::touch::IOSTouchRect& candidate_frame = resolved_control_frames_[control_index]; + const CGFloat candidate_center_x = candidate_frame.x + candidate_frame.width * 0.5f; + const CGFloat candidate_center_y = candidate_frame.y + candidate_frame.height * 0.5f; + const CGFloat distance = std::hypot(candidate_center_x - selected_center_x, + candidate_center_y - selected_center_y); + if (distance < best_distance) { + best_distance = distance; + best_index = control_index; + } + } + return best_index; + }; + + NSUInteger match_index = find_best_match(YES); + return match_index; +} + +- (void)matchSelectedControlSizeToNearestSibling { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + NSUInteger match_index = [self nearestSizeMatchControlIndexForSelectedControl]; + if (match_index == NSNotFound || selected_control_index_ == NSNotFound || + selected_control_index_ >= resolved_control_frames_.size() || + match_index >= resolved_control_frames_.size()) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ >= controls.size() || match_index >= controls.size()) { + return; + } + + const xe::hid::touch::IOSTouchRect& selected_frame = + resolved_control_frames_[selected_control_index_]; + const xe::hid::touch::IOSTouchRect& match_frame = resolved_control_frames_[match_index]; + xe::hid::touch::IOSTouchRect candidate = { + selected_frame.x + (selected_frame.width - match_frame.width) * 0.5f, + selected_frame.y + (selected_frame.height - match_frame.height) * 0.5f, + match_frame.width, + match_frame.height, + }; + const bool match_is_portrait = TouchOverlayIsPortraitForView(self); + xe::hid::touch::IOSTouchControlDefinition& match_control = controls[selected_control_index_]; + xe::hid::touch::IOSTouchLayoutSpace position_space = TouchSafeAreaSpaceForView(self); + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, match_control.type); + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return; + } + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::IOSTouchRect& match_active_frame = + xe::hid::touch::MutableActiveControlFrameForOrientation(match_control, match_is_portrait); + match_active_frame = NormalizedControlFrameFromResolvedFrame(candidate, position_space, + size_space, match_control.type); + const xe::hid::touch::IOSTouchRect committed_match_frame = match_active_frame; + + for (TouchCaptureState& capture : active_captures_) { + if (capture.control_index == selected_control_index_) { + capture.normalized_frame_at_capture = committed_match_frame; + capture.anchor_point = capture.current_point; + } + } + + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (CGRect)preferredEditChromeFrameForSafeArea:(const xe::hid::touch::IOSTouchLayoutSpace&)safe_area + width:(CGFloat)chrome_width + height:(CGFloat)chrome_height { + const CGFloat chrome_margin = 14.0f; + const auto candidates = + EditChromeDockCandidateFrames(safe_area, chrome_margin, chrome_width, chrome_height); + + if (!runtime_model_) { + return candidates[0]; + } + + const auto& controls = runtime_model_->layout().controls; + CGFloat best_penalty = CGFLOAT_MAX; + CGRect best_frame = candidates[0]; + for (CGRect candidate : candidates) { + CGFloat penalty = 0.0f; + for (NSUInteger control_index = 0; + control_index < MIN(static_cast(controls.size()), + static_cast(resolved_control_frames_.size())); + ++control_index) { + if (controls[control_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + continue; + } + CGRect control_frame = CGRectFromTouchRect(resolved_control_frames_[control_index]); + CGRect intersection = CGRectIntersection(candidate, control_frame); + if (CGRectIsNull(intersection) || CGRectIsEmpty(intersection)) { + continue; + } + CGFloat weight = 1.0f; + if (control_index == selected_control_index_) { + weight = 5.0f; + } else if (control_index == pause_control_index_) { + weight = 3.0f; + } + penalty += CGRectGetWidth(intersection) * CGRectGetHeight(intersection) * weight; + } + if (penalty < best_penalty) { + best_penalty = penalty; + best_frame = candidate; + } + } + return best_frame; +} + +- (CGRect)editChromeFrameForDockIndex:(NSInteger)dock_index + safeArea:(const xe::hid::touch::IOSTouchLayoutSpace&)safe_area + width:(CGFloat)chrome_width + height:(CGFloat)chrome_height { + const auto candidates = + EditChromeDockCandidateFrames(safe_area, 14.0f, chrome_width, chrome_height); + if (dock_index < 0 || dock_index >= static_cast(candidates.size())) { + return [self preferredEditChromeFrameForSafeArea:safe_area + width:chrome_width + height:chrome_height]; + } + return candidates[static_cast(dock_index)]; +} + +- (CGRect)clampedEditChromeFrame:(CGRect)frame + safeArea:(const xe::hid::touch::IOSTouchLayoutSpace&)safe_area { + const CGFloat chrome_margin = 14.0f; + const CGFloat min_x = safe_area.origin_x + chrome_margin; + const CGFloat max_x = + MAX(min_x, safe_area.origin_x + safe_area.width - CGRectGetWidth(frame) - chrome_margin); + const CGFloat min_y = safe_area.origin_y + chrome_margin; + const CGFloat max_y = + MAX(min_y, safe_area.origin_y + safe_area.height - CGRectGetHeight(frame) - chrome_margin); + frame.origin.x = std::clamp(CGRectGetMinX(frame), min_x, max_x); + frame.origin.y = std::clamp(CGRectGetMinY(frame), min_y, max_y); + return CGRectIntegral(frame); +} + +- (NSInteger)nearestEditChromeDockIndexForFrame:(CGRect)frame + safeArea: + (const xe::hid::touch::IOSTouchLayoutSpace&)safe_area + width:(CGFloat)chrome_width + height:(CGFloat)chrome_height { + const auto candidates = + EditChromeDockCandidateFrames(safe_area, 14.0f, chrome_width, chrome_height); + CGPoint frame_center = CGPointMake(CGRectGetMidX(frame), CGRectGetMidY(frame)); + NSInteger best_index = 0; + CGFloat best_distance = CGFLOAT_MAX; + for (NSInteger candidate_index = 0; candidate_index < static_cast(candidates.size()); + ++candidate_index) { + CGPoint candidate_center = + CGPointMake(CGRectGetMidX(candidates[static_cast(candidate_index)]), + CGRectGetMidY(candidates[static_cast(candidate_index)])); + const CGFloat distance = + std::hypot(frame_center.x - candidate_center.x, frame_center.y - candidate_center.y); + if (distance < best_distance) { + best_distance = distance; + best_index = candidate_index; + } + } + return best_index; +} + +- (CGRect)resolvedEditChromeFrameForSafeArea:(const xe::hid::touch::IOSTouchLayoutSpace&)safe_area + width:(CGFloat)chrome_width + height:(CGFloat)chrome_height { + if (edit_chrome_drag_active_) { + return [self clampedEditChromeFrame:edit_chrome_drag_frame_ safeArea:safe_area]; + } + if (edit_chrome_dock_index_ != kEditChromeDockAuto) { + return [self editChromeFrameForDockIndex:edit_chrome_dock_index_ + safeArea:safe_area + width:chrome_width + height:chrome_height]; + } + return [self preferredEditChromeFrameForSafeArea:safe_area + width:chrome_width + height:chrome_height]; +} + +- (CGRect)editChromeHeaderDragFrame { + if (edit_chrome_.hidden) { + return CGRectZero; + } + return CGRectMake(CGRectGetMinX(edit_chrome_.frame), CGRectGetMinY(edit_chrome_.frame), + CGRectGetWidth(edit_chrome_.frame), + MIN(kEditChromeHeaderHeight, CGRectGetHeight(edit_chrome_.frame))); +} + +- (void)clearEditChromeDragState { + edit_chrome_drag_active_ = NO; + edit_chrome_drag_touch_ = nil; + edit_chrome_drag_touch_offset_ = CGPointZero; +} + +- (void)clearEditSnapGuides { + active_snap_vertical_guides_.clear(); + active_snap_horizontal_guides_.clear(); + [self updateEditSnapGuidesPath]; +} + +- (void)updateEditSnapGuidesPath { + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(self); + const bool guides_empty = + !editing_controls_enabled_ || safe_area.IsEmpty() || + (active_snap_vertical_guides_.empty() && active_snap_horizontal_guides_.empty()); + + // Keep the overlay/layer non-hidden while in edit mode and animate opacity + // instead of toggling hidden — this lets the guides fade in/out smoothly as + // the user drags a control past alignment positions, rather than flashing + // in/out instantly. + edit_snap_guides_overlay_.hidden = !editing_controls_enabled_; + edit_snap_guides_layer_.hidden = !editing_controls_enabled_; + + if (!guides_empty) { + UIBezierPath* guide_path = [UIBezierPath bezierPath]; + for (CGFloat x : active_snap_vertical_guides_) { + [guide_path moveToPoint:CGPointMake(x, safe_area.origin_y)]; + [guide_path addLineToPoint:CGPointMake(x, safe_area.origin_y + safe_area.height)]; + } + for (CGFloat y : active_snap_horizontal_guides_) { + [guide_path moveToPoint:CGPointMake(safe_area.origin_x, y)]; + [guide_path addLineToPoint:CGPointMake(safe_area.origin_x + safe_area.width, y)]; + } + edit_snap_guides_layer_.path = guide_path.CGPath; + } + + [CATransaction begin]; + [CATransaction setAnimationDuration:0.10]; + [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseOut]]; + edit_snap_guides_layer_.opacity = guides_empty ? 0.0f : 1.0f; + [CATransaction commit]; + + // Rising-edge snap haptic: only fire when guides become visible (a fresh + // alignment engagement), not on every drag tick that keeps them present. + const BOOL guides_visible_now = !guides_empty; + if (guides_visible_now && !snap_guides_were_visible_) { + [self playSnapHaptic]; + } + snap_guides_were_visible_ = guides_visible_now; +} + +- (void)clearEditPinchState { + edit_pinch_active_ = NO; + edit_pinch_control_index_ = NSNotFound; + edit_pinch_touch_a_ = nil; + edit_pinch_touch_b_ = nil; + edit_pinch_initial_distance_ = 0.0f; + edit_pinch_initial_frame_ = {}; +} + +- (void)clearLookMotionState { + std::fill(recent_look_vectors_.begin(), recent_look_vectors_.end(), CGPointZero); + std::fill(recent_look_motion_times_.begin(), recent_look_motion_times_.end(), 0.0); +} + +- (void)clearLookMotionStateForControlIndex:(NSUInteger)control_index { + if (control_index >= recent_look_vectors_.size() || + control_index >= recent_look_motion_times_.size()) { + return; + } + recent_look_vectors_[control_index] = CGPointZero; + recent_look_motion_times_[control_index] = 0.0; +} + +- (void)storeLookMotion:(CGPoint)look_vector + forControlIndex:(NSUInteger)control_index + atTime:(CFTimeInterval)current_time { + if (control_index >= recent_look_vectors_.size() || + control_index >= recent_look_motion_times_.size()) { + return; + } + recent_look_vectors_[control_index] = look_vector; + recent_look_motion_times_[control_index] = current_time; +} + +- (xe::hid::touch::IOSTouchRect) + snappedResolvedFrameForControlIndex:(NSUInteger)control_index + candidateFrame:(const xe::hid::touch::IOSTouchRect&)candidate_frame + safeArea:(const xe::hid::touch::IOSTouchLayoutSpace&)safe_area + gestureMode:(TouchCaptureState::EditGestureMode)gesture_mode + preserveAspectRatio:(BOOL)preserve_aspect_ratio + preserveCenter:(BOOL)preserve_center { + const auto& controls = runtime_model_->layout().controls; + if (control_index >= controls.size()) { + return candidate_frame; + } + const auto& control = controls[control_index]; + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, control.type); + if (safe_area.IsEmpty() || size_space.IsEmpty()) { + return candidate_frame; + } + + TouchEditSnapOptions options; + options.grid_enabled = edit_grid_enabled_; + options.grid_spacing = kEditGridSpacingPoints; + options.move_snap_threshold = + edit_grid_enabled_ ? kEditGridMoveSnapThresholdPoints : kEditMoveSnapThresholdPoints; + options.resize_snap_threshold = + edit_grid_enabled_ ? kEditGridResizeSnapThresholdPoints : kEditResizeSnapThresholdPoints; + options.canonical_control_sizes = kEditCanonicalControlSizes; + options.canonical_control_size_count = + sizeof(kEditCanonicalControlSizes) / sizeof(kEditCanonicalControlSizes[0]); + TouchEditSnapResult result = SnapTouchEditResolvedFrame( + control_index, controls, resolved_control_frames_, candidate_frame, safe_area, size_space, + gesture_mode == TouchCaptureState::EditGestureMode::kMove ? TouchEditGestureMode::kMove + : TouchEditGestureMode::kResize, + preserve_aspect_ratio, preserve_center, options); + active_snap_vertical_guides_ = std::move(result.vertical_guides); + active_snap_horizontal_guides_ = std::move(result.horizontal_guides); + return result.frame; +} + +- (BOOL)tryBeginEditPinchWithTouch:(UITouch*)touch atPoint:(CGPoint)point { + if (!editing_controls_enabled_ || !runtime_model_ || edit_pinch_active_ || + selected_control_index_ == NSNotFound || + selected_control_index_ >= runtime_model_->layout().controls.size() || + selected_control_index_ >= resolved_control_frames_.size()) { + return NO; + } + + const auto& controls = runtime_model_->layout().controls; + const auto& control = controls[selected_control_index_]; + if (!TouchControlContainsPoint(control, resolved_control_frames_[selected_control_index_], + point)) { + return NO; + } + + const NSUInteger selected_control_index = selected_control_index_; + auto primary_capture_it = + std::find_if(active_captures_.begin(), active_captures_.end(), + [selected_control_index](const TouchCaptureState& capture) { + return capture.control_index == selected_control_index && + capture.edit_gesture_mode == TouchCaptureState::EditGestureMode::kMove; + }); + if (primary_capture_it == active_captures_.end()) { + return NO; + } + + CGPoint primary_point = [primary_capture_it->touch locationInView:self]; + const CGFloat initial_distance = std::hypot(primary_point.x - point.x, primary_point.y - point.y); + if (initial_distance < 18.0f) { + return NO; + } + + [self beginEditLayoutChangeIfNeeded]; + edit_pinch_active_ = YES; + edit_pinch_control_index_ = selected_control_index_; + edit_pinch_touch_a_ = primary_capture_it->touch; + edit_pinch_touch_b_ = touch; + edit_pinch_initial_distance_ = initial_distance; + // Pinch always operates on the orientation currently being edited; the + // commit path (updatePinchedControlFrame) writes back through the same + // orientation choke point. + const bool pinch_begin_is_portrait = TouchOverlayIsPortraitForView(self); + edit_pinch_initial_frame_ = xe::hid::touch::ActiveControlFrameForOrientation( + controls[selected_control_index_], pinch_begin_is_portrait); + primary_capture_it->current_point = primary_point; + primary_capture_it->normalized_frame_at_capture = edit_pinch_initial_frame_; + [self clearEditSnapGuides]; + return YES; +} + +- (void)endEditPinchRetainingTouch:(UITouch*)remaining_touch { + const NSUInteger pinch_control_index = edit_pinch_control_index_; + xe::hid::touch::IOSTouchRect current_frame = {}; + if (runtime_model_ && pinch_control_index != NSNotFound && + pinch_control_index < runtime_model_->layout().controls.size()) { + const bool pinch_end_is_portrait = TouchOverlayIsPortraitForView(self); + current_frame = xe::hid::touch::ActiveControlFrameForOrientation( + runtime_model_->layout().controls[pinch_control_index], pinch_end_is_portrait); + } + + [self clearEditPinchState]; + [self clearEditSnapGuides]; + + if (!remaining_touch || !runtime_model_ || pinch_control_index == NSNotFound || + pinch_control_index >= runtime_model_->layout().controls.size()) { + return; + } + + CGPoint remaining_point = [remaining_touch locationInView:self]; + auto capture_it = std::find_if(active_captures_.begin(), active_captures_.end(), + [remaining_touch](const TouchCaptureState& capture) { + return capture.touch == remaining_touch; + }); + if (capture_it != active_captures_.end()) { + capture_it->control_index = pinch_control_index; + capture_it->anchor_point = remaining_point; + capture_it->current_point = remaining_point; + capture_it->began_time = CACurrentMediaTime(); + capture_it->normalized_frame_at_capture = current_frame; + capture_it->edit_gesture_mode = TouchCaptureState::EditGestureMode::kMove; + return; + } + + TouchCaptureState capture; + capture.touch = remaining_touch; + capture.control_index = pinch_control_index; + capture.anchor_point = remaining_point; + capture.current_point = remaining_point; + capture.began_time = CACurrentMediaTime(); + capture.normalized_frame_at_capture = current_frame; + capture.edit_gesture_mode = TouchCaptureState::EditGestureMode::kMove; + active_captures_.push_back(capture); +} + +- (void)updatePinchedControlFrame { + if (!editing_controls_enabled_ || !runtime_model_ || !edit_pinch_active_ || + edit_pinch_control_index_ == NSNotFound || + edit_pinch_control_index_ >= runtime_model_->layout().controls.size()) { + return; + } + + xe::hid::touch::IOSTouchControlDefinition& pinch_control = + runtime_model_->mutable_layout().controls[edit_pinch_control_index_]; + xe::hid::touch::IOSTouchLayoutSpace position_space = TouchSafeAreaSpaceForView(self); + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, pinch_control.type); + if (position_space.IsEmpty() || size_space.IsEmpty() || edit_pinch_initial_distance_ <= 0.0f) { + return; + } + + CGPoint point_a = [edit_pinch_touch_a_ locationInView:self]; + CGPoint point_b = [edit_pinch_touch_b_ locationInView:self]; + const CGFloat current_distance = std::hypot(point_a.x - point_b.x, point_a.y - point_b.y); + const float scale = + std::clamp(static_cast(current_distance / edit_pinch_initial_distance_), 0.45f, 2.75f); + xe::hid::touch::IOSTouchRect initial_resolved = ResolveNormalizedControlFrame( + edit_pinch_initial_frame_, position_space, size_space, pinch_control.type); + const float center_x = initial_resolved.x + initial_resolved.width * 0.5f; + const float center_y = initial_resolved.y + initial_resolved.height * 0.5f; + xe::hid::touch::IOSTouchRect candidate = initial_resolved; + candidate.width *= scale; + candidate.height *= scale; + candidate.x = center_x - candidate.width * 0.5f; + candidate.y = center_y - candidate.height * 0.5f; + + xe::hid::touch::IOSTouchRect snapped = + [self snappedResolvedFrameForControlIndex:edit_pinch_control_index_ + candidateFrame:candidate + safeArea:position_space + gestureMode:TouchCaptureState::EditGestureMode::kResize + preserveAspectRatio:YES + preserveCenter:YES]; + [self beginEditLayoutChangeIfNeeded]; + const bool pinch_commit_is_portrait = TouchOverlayIsPortraitForView(self); + xe::hid::touch::IOSTouchRect& pinch_active_frame = + xe::hid::touch::MutableActiveControlFrameForOrientation(pinch_control, + pinch_commit_is_portrait); + pinch_active_frame = NormalizedControlFrameFromResolvedFrame(snapped, position_space, size_space, + pinch_control.type); + const xe::hid::touch::IOSTouchRect committed_pinch_frame = pinch_active_frame; + + UITouch* pinch_touch_a = edit_pinch_touch_a_; + auto capture_it = std::find_if( + active_captures_.begin(), active_captures_.end(), + [pinch_touch_a](const TouchCaptureState& capture) { return capture.touch == pinch_touch_a; }); + if (capture_it != active_captures_.end()) { + capture_it->normalized_frame_at_capture = committed_pinch_frame; + capture_it->current_point = point_a; + } +} + +- (void)updateConflictHighlights { + const NSUInteger control_count = + MIN(control_views_.count, static_cast(resolved_control_frames_.size())); + conflicting_control_indices_.assign(control_count, false); + + if (!runtime_model_ || !editing_controls_enabled_) { + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + [[control_views_ objectAtIndex:control_index] setConflictHighlighted:NO]; + } + return; + } + + const auto& controls = runtime_model_->layout().controls; + for (NSUInteger left_index = 0; left_index < control_count; ++left_index) { + if (controls[left_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + continue; + } + CGRect left_frame = CGRectFromTouchRect(resolved_control_frames_[left_index]); + for (NSUInteger right_index = left_index + 1; right_index < control_count; ++right_index) { + if (controls[right_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + continue; + } + CGRect right_frame = CGRectFromTouchRect(resolved_control_frames_[right_index]); + if (CGRectIntersectsRect(left_frame, right_frame)) { + conflicting_control_indices_[left_index] = true; + conflicting_control_indices_[right_index] = true; + } + } + } + + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + [[control_views_ objectAtIndex:control_index] + setConflictHighlighted:conflicting_control_indices_[control_index]]; + } +} + +- (void)selectControlWithIdentifier:(const std::string&)identifier { + if (!runtime_model_) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + for (NSUInteger control_index = 0; control_index < controls.size(); ++control_index) { + if (controls[control_index].identifier == identifier) { + [self setSelectedControlIndex:control_index]; + return; + } + } +} + +- (void)setSelectedControlIndex:(NSUInteger)selected_control_index { + const BOOL changed = selected_control_index_ != selected_control_index; + selected_control_index_ = selected_control_index; + [self clearEditSnapGuides]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + if (changed && editing_controls_enabled_ && selected_control_index != NSNotFound) { + [self playSelectionHaptic]; + } +} + +- (void)resetInteractionState { + active_captures_.clear(); + [self clearEditChromeDragState]; + [self clearEditPinchState]; + [self clearEditSnapGuides]; + std::fill(recent_action_press_times_.begin(), recent_action_press_times_.end(), 0.0); + std::fill(recent_secondary_press_times_.begin(), recent_secondary_press_times_.end(), 0.0); + std::fill(recent_secondary_candidate_times_.begin(), recent_secondary_candidate_times_.end(), + 0.0); + [self clearLookMotionState]; + move_knob_.hidden = YES; + for (XeniaTouchControlShellView* control_view in control_views_) { + [control_view setTouchActive:NO]; + } +} + +- (void)syncControlViewDefinitions { + if (!runtime_model_) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + [[control_views_ objectAtIndex:control_index] applyControlDefinition:controls[control_index]]; + } + [self refreshEditPreview]; +} + +- (BOOL)isEditingControlsEnabled { + return editing_controls_enabled_; +} + +- (void)setEditingControlsEnabled:(BOOL)enabled animated:(BOOL)animated { + if (editing_controls_enabled_ == enabled) { + return; + } + + editing_controls_enabled_ = enabled; + [self resetInteractionState]; + if (enabled) { + [self resetEditLayoutHistory]; + edit_chrome_minimized_ = NO; + // Restore the dock corner the user last left the chrome at, so the + // editor opens in the same place across sessions instead of resetting + // to the default each time. + NSInteger persisted_dock = + [[NSUserDefaults standardUserDefaults] integerForKey:@"XeniaTouchEditChromeDock"]; + edit_chrome_dock_index_ = (persisted_dock >= 0 && persisted_dock < kEditChromeDockCount) + ? persisted_dock + : kEditChromeDockAuto; + [self clearEditChromeDragState]; + if (selected_control_index_ == NSNotFound && runtime_model_) { + const auto& controls = runtime_model_->layout().controls; + if (!controls.empty()) { + [self + setSelectedControlIndex:(move_control_index_ != NSNotFound ? move_control_index_ : 0)]; + } + } else { + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + } + edit_grid_overlay_.hidden = !edit_grid_enabled_; + edit_grid_overlay_.alpha = edit_grid_enabled_ ? 1.0 : 0.0; + edit_safe_area_guide_.hidden = NO; + edit_chrome_.hidden = NO; + edit_snap_guides_overlay_.hidden = YES; + [self seedEditLayoutHistoryIfNeeded]; + } else { + [self resetEditLayoutHistory]; + edit_showing_layout_library_ = NO; + edit_chrome_minimized_ = NO; + edit_chrome_dock_index_ = kEditChromeDockAuto; + [self clearEditChromeDragState]; + } + // First-responder dance so hardware-keyboard `keyCommands` (⌘Z, ⇧⌘Z, ⌘D, + // ⌘⌫, ⌘M, Esc) are routed to us only while the editor is active. + if (enabled) { + [self becomeFirstResponder]; + } else { + [self resignFirstResponder]; + } + [self setNeedsLayout]; + [self publishResolvedState]; + + if (!animated) { + edit_chrome_.alpha = enabled ? 1.0 : 0.0; + edit_safe_area_guide_.alpha = enabled ? 1.0 : 0.0; + edit_snap_guides_overlay_.alpha = enabled ? 1.0 : 0.0; + edit_grid_overlay_.hidden = !enabled || !edit_grid_enabled_; + edit_safe_area_guide_.hidden = !enabled; + edit_chrome_.hidden = !enabled; + edit_snap_guides_overlay_.hidden = YES; + return; + } + + if (enabled) { + edit_chrome_.alpha = 0.0; + edit_safe_area_guide_.alpha = 0.0; + edit_grid_overlay_.alpha = 0.0; + } + [UIView animateWithDuration:0.18 + animations:^{ + edit_chrome_.alpha = enabled ? 1.0 : 0.0; + edit_safe_area_guide_.alpha = enabled ? 1.0 : 0.0; + edit_grid_overlay_.alpha = (enabled && edit_grid_enabled_) ? 1.0 : 0.0; + } + completion:^(__unused BOOL finished) { + if (!enabled) { + edit_snap_guides_overlay_.hidden = YES; + edit_grid_overlay_.hidden = YES; + edit_safe_area_guide_.hidden = YES; + edit_chrome_.hidden = YES; + } + }]; +} + +- (void)adjustSelectedControlSizeByScale:(float)scale { + if (!editing_controls_enabled_ || !runtime_model_ || scale <= 0.0f) { + return; + } + + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + xe::hid::touch::IOSTouchLayoutSpace position_space = TouchSafeAreaSpaceForView(self); + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, control.type); + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return; + } + + const bool size_adjust_is_portrait = TouchOverlayIsPortraitForView(self); + const xe::hid::touch::IOSTouchRect& size_active_source = + xe::hid::touch::ActiveControlFrameForOrientation(control, size_adjust_is_portrait); + xe::hid::touch::IOSTouchRect resolved = + ResolveNormalizedControlFrame(size_active_source, position_space, size_space, control.type); + const float center_x = resolved.x + resolved.width * 0.5f; + const float center_y = resolved.y + resolved.height * 0.5f; + xe::hid::touch::IOSTouchRect candidate = resolved; + candidate.width *= scale; + candidate.height *= scale; + candidate.x = center_x - candidate.width * 0.5f; + candidate.y = center_y - candidate.height * 0.5f; + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::IOSTouchRect snapped = + [self snappedResolvedFrameForControlIndex:selected_control_index_ + candidateFrame:candidate + safeArea:position_space + gestureMode:TouchCaptureState::EditGestureMode::kResize + preserveAspectRatio:YES + preserveCenter:YES]; + xe::hid::touch::IOSTouchRect& size_active_frame = + xe::hid::touch::MutableActiveControlFrameForOrientation(control, size_adjust_is_portrait); + size_active_frame = + NormalizedControlFrameFromResolvedFrame(snapped, position_space, size_space, control.type); + const xe::hid::touch::IOSTouchRect committed_size_frame = size_active_frame; + + for (TouchCaptureState& capture : active_captures_) { + if (capture.control_index == selected_control_index_) { + capture.normalized_frame_at_capture = committed_size_frame; + capture.anchor_point = capture.current_point; + } + } + if (active_captures_.empty() && !edit_pinch_active_) { + [self clearEditSnapGuides]; + } + + [self syncControlViewDefinitions]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)adjustSelectedControlOpacityByDelta:(float)delta { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + [self beginEditLayoutChangeIfNeeded]; + control.visual_opacity = std::clamp(control.visual_opacity + delta, 0.15f, 1.0f); + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)cycleSelectedControlAction { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.type != xe::hid::touch::IOSTouchControlType::kActionButton) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::ConfigureIOSTouchControlAction( + xe::hid::touch::NextEditableIOSTouchAction(control.action), &control); + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)cycleSelectedControlTint { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + [self beginEditLayoutChangeIfNeeded]; + control.tint_style = xe::hid::touch::NextIOSTouchTintStyle(control.tint_style); + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self applyCaptureVisualState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)cycleSelectedControlShape { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& controls = runtime_model_->mutable_layout().controls; + if (selected_control_index_ == NSNotFound || selected_control_index_ >= controls.size()) { + return; + } + + auto& control = controls[selected_control_index_]; + if (control.type != xe::hid::touch::IOSTouchControlType::kActionButton) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + if (control.shape == xe::hid::touch::IOSTouchControlShape::kCircle) { + control.shape = xe::hid::touch::IOSTouchControlShape::kRoundedRect; + } else { + control.shape = xe::hid::touch::IOSTouchControlShape::kCircle; + } + + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)addNewActionButton { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + auto& layout = runtime_model_->mutable_layout(); + if (layout.controls.size() >= xe::hid::touch::kMaxIOSTouchControls) { + return; + } + + [self clearEditSnapGuides]; + [self beginEditLayoutChangeIfNeeded]; + std::string selected_identifier; + if (!xe::hid::touch::AddSuggestedActionButtonToIOSTouchLayout( + &layout, TouchOverlayIsPortraitForView(self), &selected_identifier)) { + [self finishEditLayoutChangeIfNeeded]; + return; + } + [self refreshLayoutModel]; + [self selectControlWithIdentifier:selected_identifier]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)mirrorSelectedControlHorizontally { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& layout = runtime_model_->mutable_layout(); + if (selected_control_index_ == NSNotFound || selected_control_index_ >= layout.controls.size()) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + if (!xe::hid::touch::MirrorIOSTouchLayoutControlHorizontally( + &layout, selected_control_index_, TouchOverlayIsPortraitForView(self))) { + [self finishEditLayoutChangeIfNeeded]; + return; + } + const auto& control = layout.controls[selected_control_index_]; + [self refreshLayoutModel]; + [self selectControlWithIdentifier:control.identifier]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)copyAllControlFramesAcrossOrientationsFromLandscape:(BOOL)from_landscape { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& layout = runtime_model_->mutable_layout(); + if (layout.controls.empty()) { + return; + } + + std::string selected_identifier; + if (selected_control_index_ != NSNotFound && selected_control_index_ < layout.controls.size()) { + selected_identifier = layout.controls[selected_control_index_].identifier; + } + + [self beginEditLayoutChangeIfNeeded]; + if (!xe::hid::touch::CopyIOSTouchLayoutFramesAcrossOrientations(&layout, from_landscape)) { + [self finishEditLayoutChangeIfNeeded]; + return; + } + + [self refreshLayoutModel]; + if (!selected_identifier.empty()) { + [self selectControlWithIdentifier:selected_identifier]; + } + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)toggleSelectedControlMoveDpadRing { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& layout = runtime_model_->mutable_layout(); + if (selected_control_index_ == NSNotFound || selected_control_index_ >= layout.controls.size()) { + return; + } + + auto& control = layout.controls[selected_control_index_]; + if (control.type != xe::hid::touch::IOSTouchControlType::kMoveStick) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + control.move_with_dpad_ring = !control.move_with_dpad_ring; + [self syncControlViewDefinitions]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self setNeedsLayout]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)duplicateSelectedControl { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& layout = runtime_model_->mutable_layout(); + if (selected_control_index_ == NSNotFound || selected_control_index_ >= layout.controls.size()) { + return; + } + if (layout.controls.size() >= xe::hid::touch::kMaxIOSTouchControls) { + return; + } + + const auto& source_control = layout.controls[selected_control_index_]; + if (source_control.type != xe::hid::touch::IOSTouchControlType::kActionButton) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + std::string selected_identifier; + if (!xe::hid::touch::DuplicateIOSTouchLayoutActionButton(&layout, selected_control_index_, + TouchOverlayIsPortraitForView(self), + &selected_identifier)) { + [self finishEditLayoutChangeIfNeeded]; + return; + } + [self refreshLayoutModel]; + [self selectControlWithIdentifier:selected_identifier]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)deleteSelectedControl { + if (!editing_controls_enabled_ || !runtime_model_) { + return; + } + + [self clearEditSnapGuides]; + auto& layout = runtime_model_->mutable_layout(); + if (selected_control_index_ == NSNotFound || selected_control_index_ >= layout.controls.size()) { + return; + } + if (layout.controls.size() <= 1) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + if (!xe::hid::touch::DeleteIOSTouchLayoutControl(&layout, selected_control_index_)) { + [self finishEditLayoutChangeIfNeeded]; + return; + } + selected_control_index_ = NSNotFound; + [self refreshLayoutModel]; + [self publishResolvedState]; + [self finishEditLayoutChangeIfNeeded]; +} + +- (void)updateControlFrameForCapture:(TouchCaptureState&)capture newPoint:(CGPoint)new_point { + if (!editing_controls_enabled_ || !runtime_model_ || capture.control_index == NSNotFound) { + return; + } + + auto& controls = runtime_model_->mutable_layout().controls; + if (capture.control_index >= controls.size()) { + return; + } + xe::hid::touch::IOSTouchControlDefinition& drag_control = controls[capture.control_index]; + xe::hid::touch::IOSTouchLayoutSpace position_space = TouchSafeAreaSpaceForView(self); + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, drag_control.type); + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return; + } + + [self beginEditLayoutChangeIfNeeded]; + xe::hid::touch::IOSTouchRect next_frame = ResolveNormalizedControlFrame( + capture.normalized_frame_at_capture, position_space, size_space, drag_control.type); + if (capture.edit_gesture_mode == TouchCaptureState::EditGestureMode::kResize) { + next_frame.width += static_cast(new_point.x - capture.anchor_point.x); + next_frame.height += static_cast(new_point.y - capture.anchor_point.y); + } else { + next_frame.x += static_cast(new_point.x - capture.anchor_point.x); + next_frame.y += static_cast(new_point.y - capture.anchor_point.y); + } + xe::hid::touch::IOSTouchRect snapped = + [self snappedResolvedFrameForControlIndex:capture.control_index + candidateFrame:next_frame + safeArea:position_space + gestureMode:capture.edit_gesture_mode + preserveAspectRatio:NO + preserveCenter:NO]; + // Drag math operates on the orientation-resolved baseline that was + // captured at touchesBegan; route the commit back through the same + // orientation choke point so portrait drags only mutate the portrait + // override (and lazily promote it the very first time). + const bool drag_commit_is_portrait = TouchOverlayIsPortraitForView(self); + xe::hid::touch::IOSTouchRect& drag_active_frame = + xe::hid::touch::MutableActiveControlFrameForOrientation(drag_control, + drag_commit_is_portrait); + drag_active_frame = NormalizedControlFrameFromResolvedFrame(snapped, position_space, size_space, + drag_control.type); +} + +- (void)applyCaptureVisualState { + if (!runtime_model_) { + move_knob_.hidden = YES; + return; + } + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + if (visually_active_control_indices_.size() != control_count) { + visually_active_control_indices_.resize(control_count); + } + std::fill(visually_active_control_indices_.begin(), visually_active_control_indices_.end(), 0); + const TouchCaptureState* move_capture = nullptr; + + for (const TouchCaptureState& capture : active_captures_) { + if (capture.control_index >= control_count) { + continue; + } + visually_active_control_indices_[capture.control_index] = 1; + if (capture.control_index == move_control_index_) { + move_capture = &capture; + } + } + + if (editing_controls_enabled_ && selected_control_index_ != NSNotFound && + selected_control_index_ < control_count) { + visually_active_control_indices_[selected_control_index_] = 1; + } + + for (NSUInteger control_index = 0; control_index < control_views_.count; ++control_index) { + const BOOL active = + (control_index < control_count && visually_active_control_indices_[control_index]) ? YES + : NO; + [[control_views_ objectAtIndex:control_index] setTouchActive:active]; + } + + if (editing_controls_enabled_) { + move_knob_.hidden = YES; + return; + } + + if (!move_capture || move_control_index_ == NSNotFound || + move_control_index_ >= resolved_control_frames_.size()) { + move_knob_.hidden = YES; + return; + } + + const xe::hid::touch::IOSTouchRect& move_frame = resolved_control_frames_[move_control_index_]; + const xe::hid::touch::IOSTouchControlDefinition& move_control = controls[move_control_index_]; + const CGFloat outer_radius = + MIN(move_frame.width, move_frame.height) * MAX(move_control.activation_radius, 0.24f); + CGPoint delta = CGPointMake(move_capture->current_point.x - move_capture->anchor_point.x, + move_capture->current_point.y - move_capture->anchor_point.y); + const CGFloat distance = std::hypot(delta.x, delta.y); + if (distance > outer_radius && distance > 0.0f) { + const CGFloat scale = outer_radius / distance; + delta.x *= scale; + delta.y *= scale; + } + + const CGFloat knob_size = MIN(move_frame.width, move_frame.height) * 0.28f; + move_knob_.bounds = CGRectMake(0.0f, 0.0f, knob_size, knob_size); + move_knob_.center = + CGPointMake(move_capture->anchor_point.x + delta.x, move_capture->anchor_point.y + delta.y); + move_knob_.layer.cornerRadius = knob_size * 0.5f; + move_knob_.hidden = NO; +} + +- (void)publishResolvedState { + if (!runtime_model_) { + return; + } + + xe::hid::touch::IOSTouchResolvedState state = {}; + state.gameplay_enabled = gameplay_overlay_active_ && !editing_controls_enabled_; + + if (editing_controls_enabled_) { + if (!xe::hid::touch::TouchStatesEqualIgnoringPacket(state, last_published_state_)) { + state.packet_number = next_packet_number_++; + if (next_packet_number_ == 0) { + next_packet_number_ = 1; + } + last_published_state_ = state; + } else { + state.packet_number = last_published_state_.packet_number; + } + + runtime_model_->StoreResolvedState(state); + [self applyCaptureVisualState]; + return; + } + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + const CFTimeInterval current_time = CACurrentMediaTime(); + + for (const TouchCaptureState& capture : active_captures_) { + if (capture.control_index >= control_count || + capture.control_index >= resolved_control_frames_.size()) { + continue; + } + + const xe::hid::touch::IOSTouchControlDefinition& control = controls[capture.control_index]; + const xe::hid::touch::IOSTouchRect& frame = resolved_control_frames_[capture.control_index]; + + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kMoveStick: { + // Move + D-Pad combo: a touch that landed on one of the four arrow + // zones drives the corresponding D-Pad bit (press-and-hold while the + // finger remains on the arrow) instead of the analog stick. The + // sub-zone is decided once at touchesBegan and pinned for the + // capture's lifetime, so a finger drifting toward the centre doesn't + // silently turn into a stick deflection. + switch (capture.combo_subzone) { + case TouchCaptureState::ComboSubzone::kDpadUp: + state.buttons |= xe::hid::X_INPUT_GAMEPAD_DPAD_UP; + continue; + case TouchCaptureState::ComboSubzone::kDpadDown: + state.buttons |= xe::hid::X_INPUT_GAMEPAD_DPAD_DOWN; + continue; + case TouchCaptureState::ComboSubzone::kDpadLeft: + state.buttons |= xe::hid::X_INPUT_GAMEPAD_DPAD_LEFT; + continue; + case TouchCaptureState::ComboSubzone::kDpadRight: + state.buttons |= xe::hid::X_INPUT_GAMEPAD_DPAD_RIGHT; + continue; + case TouchCaptureState::ComboSubzone::kStick: + case TouchCaptureState::ComboSubzone::kNone: + break; + } + const CGPoint move_unit = MoveStickUnitVectorForCapture(control, frame, capture); + if (!CGPointEqualToPoint(move_unit, CGPointZero)) { + const float normalized_x = static_cast(move_unit.x); + const float normalized_y = static_cast(move_unit.y); + // Move-style controls can be configured with action=kLook to drive + // the right thumbstick (FPS aim) rather than the left thumbstick + // (movement). The visual + capture pipeline is identical; only the + // output target changes. The per-control look_sensitivity_scale + // (relative_look_scale) tunes the aim ramp. + if (control.action == xe::hid::touch::IOSTouchAction::kLook) { + const float look_scale = std::clamp(control.relative_look_scale, 0.25f, 4.0f); + state.thumb_rx = xe::hid::touch::TouchAxisFromUnit(normalized_x * look_scale); + state.thumb_ry = xe::hid::touch::TouchAxisFromUnit(-normalized_y * look_scale); + } else { + state.thumb_lx = xe::hid::touch::TouchAxisFromUnit(normalized_x); + state.thumb_ly = xe::hid::touch::TouchAxisFromUnit(-normalized_y); + } + } + } break; + + case xe::hid::touch::IOSTouchControlType::kActionButton: { + const TouchInteractionBehaviorState secondary_behavior_state = + ResolveTouchInteractionBehaviorState(control.secondary_behavior, capture, current_time); + const bool touch_active = control.hold_while_captured || secondary_behavior_state.active || + TouchControlContainsPoint(control, frame, capture.current_point); + if (touch_active) { + if (!xe::hid::touch::TouchControlUsesDeferredPrimaryTap(control)) { + xe::hid::touch::ApplyTouchActionMapping(control, &state); + } + if (secondary_behavior_state.active && + control.secondary_behavior.action != xe::hid::touch::IOSTouchAction::kNone) { + xe::hid::touch::ApplyTouchActionMappingForAction(control.secondary_behavior.action, + &state); + } + if (!control.hold_while_captured && + !xe::hid::touch::TouchControlUsesDeferredPrimaryTap(control) && + capture.control_index < recent_action_press_times_.size()) { + recent_action_press_times_[capture.control_index] = current_time; + } + } + } break; + + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + case xe::hid::touch::IOSTouchControlType::kPauseButton: + default: + break; + } + } + + const float button_tap_hold_seconds = TouchButtonTapHoldSeconds(); + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + if (control.type != xe::hid::touch::IOSTouchControlType::kActionButton || + control.hold_while_captured || control_index >= recent_action_press_times_.size()) { + continue; + } + + if ((current_time - recent_action_press_times_[control_index]) < button_tap_hold_seconds) { + xe::hid::touch::ApplyTouchActionMapping(control, &state); + } + } + + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + if (control.secondary_behavior.action == xe::hid::touch::IOSTouchAction::kNone || + control_index >= recent_secondary_press_times_.size()) { + continue; + } + + if ((current_time - recent_secondary_press_times_[control_index]) < button_tap_hold_seconds) { + xe::hid::touch::ApplyTouchActionMappingForAction(control.secondary_behavior.action, &state); + } + } + + // Accumulate look contributions across every control with a recent motion + // entry instead of picking only the freshest. The previous "freshest wins" + // approach trampled the look output whenever two simultaneously look-capable + // controls (e.g. an on-screen look swipe zone and an action button with + // enables_relative_look) overlapped — the more recent one would silently + // overwrite the other. Per-axis max-magnitude blending lets both contribute + // (the one moving harder wins per axis) while still allowing the per-control + // hold/decay tail to keep low-FPS gameplay polls observing transient look + // motions. + const float look_hold_seconds = TouchLookHoldSeconds(); + CGPoint accumulated_right_thumb = CGPointZero; // for action == kLook (default) + CGPoint accumulated_left_thumb = CGPointZero; // for action == kMove (cross-typed) + bool any_left_swipe = false; + const NSUInteger look_state_count = + MIN(control_count, static_cast(recent_look_motion_times_.size())); + for (NSUInteger control_index = 0; control_index < look_state_count; ++control_index) { + const CFTimeInterval motion_time = recent_look_motion_times_[control_index]; + if (motion_time <= 0.0) { + continue; + } + const CFTimeInterval look_motion_age = current_time - motion_time; + if (look_motion_age >= look_hold_seconds) { + continue; + } + const float decay = + std::clamp(1.0f - static_cast(look_motion_age / look_hold_seconds), 0.0f, 1.0f); + const CGPoint vector = recent_look_vectors_[control_index]; + const CGFloat decayed_x = vector.x * decay; + const CGFloat decayed_y = vector.y * decay; + // Route swipe-style contributions based on the source control's action: + // kMove → left thumbstick (rare, but enables a swipe-to-move overlay); + // kLook (or anything else look-capable like an action button with + // enables_relative_look) → right thumbstick. + const bool routes_to_left = + controls[control_index].action == xe::hid::touch::IOSTouchAction::kMove; + CGPoint& accumulator = routes_to_left ? accumulated_left_thumb : accumulated_right_thumb; + if (std::abs(decayed_x) > std::abs(accumulator.x)) { + accumulator.x = decayed_x; + } + if (std::abs(decayed_y) > std::abs(accumulator.y)) { + accumulator.y = decayed_y; + } + if (routes_to_left && (decayed_x != 0.0 || decayed_y != 0.0)) { + any_left_swipe = true; + } + } + // Look output: max-magnitude blend swipe contribution with whatever the + // per-capture loop above already wrote (e.g. a MoveStick configured with + // action=kLook). + const int16_t swipe_rx = + xe::hid::touch::TouchAxisFromUnit(static_cast(accumulated_right_thumb.x)); + const int16_t swipe_ry = + xe::hid::touch::TouchAxisFromUnit(static_cast(accumulated_right_thumb.y)); + if (std::abs(static_cast(swipe_rx)) > std::abs(static_cast(state.thumb_rx))) { + state.thumb_rx = swipe_rx; + } + if (std::abs(static_cast(swipe_ry)) > std::abs(static_cast(state.thumb_ry))) { + state.thumb_ry = swipe_ry; + } + // Move output via swipe (LookSwipeZone configured with action=kMove): only + // override the per-capture left thumb if a swipe contribution actually + // exists, so we don't stomp on a normal MoveStick capture. + if (any_left_swipe) { + const int16_t swipe_lx = + xe::hid::touch::TouchAxisFromUnit(static_cast(accumulated_left_thumb.x)); + const int16_t swipe_ly = + xe::hid::touch::TouchAxisFromUnit(static_cast(accumulated_left_thumb.y)); + if (std::abs(static_cast(swipe_lx)) > std::abs(static_cast(state.thumb_lx))) { + state.thumb_lx = swipe_lx; + } + if (std::abs(static_cast(swipe_ly)) > std::abs(static_cast(state.thumb_ly))) { + state.thumb_ly = swipe_ly; + } + } + + if (!xe::hid::touch::TouchStatesEqualIgnoringPacket(state, last_published_state_)) { + state.packet_number = next_packet_number_++; + if (next_packet_number_ == 0) { + next_packet_number_ = 1; + } + last_published_state_ = state; + } else { + state.packet_number = last_published_state_.packet_number; + } + + runtime_model_->StoreResolvedState(state); + [self applyCaptureVisualState]; +} + +- (void)setGameplayOverlayVisible:(BOOL)visible animated:(BOOL)animated { + gameplay_overlay_active_ = visible; + + if (visible) { + if (self.hidden) { + self.alpha = 0.0; + } + self.hidden = NO; + self.userInteractionEnabled = YES; + display_link_.paused = NO; + [self publishResolvedState]; + if (!animated) { + self.alpha = 1.0; + return; + } + // Spring-style entrance: subtle bounce so the overlay arrives with + // physical weight rather than a flat fade. + UIViewPropertyAnimator* show_animator = [[UIViewPropertyAnimator alloc] initWithDuration:0.35 + dampingRatio:0.85 + animations:^{ + self.alpha = + 1.0; + }]; + [show_animator startAnimation]; + [show_animator release]; + return; + } + + display_link_.paused = YES; + self.userInteractionEnabled = NO; + [self resetInteractionState]; + [self publishResolvedState]; + + if (!animated || self.hidden) { + self.hidden = YES; + self.alpha = 0.0; + return; + } + + UIViewPropertyAnimator* hide_animator = [[UIViewPropertyAnimator alloc] initWithDuration:0.22 + dampingRatio:1.0 + animations:^{ + self.alpha = 0.0; + }]; + [hide_animator addCompletion:^(__unused UIViewAnimatingPosition position) { + self.hidden = YES; + }]; + [hide_animator startAnimation]; + [hide_animator release]; +} + +- (void)displayLinkFired:(CADisplayLink*)__unused display_link { + [self publishResolvedState]; +} + +- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { + if (self.hidden || self.alpha <= 0.01f || !self.userInteractionEnabled || + !gameplay_overlay_active_) { + return nil; + } + + if (editing_controls_enabled_) { + UIView* chrome_hit = [edit_chrome_ interactiveHitTestForOverlayPoint:point + event:event + inView:self]; + if (chrome_hit) { + return chrome_hit; + } + if (edit_showing_layout_library_) { + if (CGRectContainsPoint(edit_chrome_.frame, point)) { + return edit_chrome_; + } + return nil; + } + if (CGRectContainsPoint([self selectedControlResizeHandleFrame], point)) { + return self; + } + if (CGRectContainsPoint([self editChromeHeaderDragFrame], point)) { + return self; + } + if (CGRectContainsPoint(edit_chrome_.frame, point)) { + return edit_chrome_; + } + } + + if (!editing_controls_enabled_ && pause_button_ && !pause_button_.hidden) { + CGPoint pause_point = [pause_button_ convertPoint:point fromView:self]; + UIView* pause_hit = [pause_button_ hitTest:pause_point withEvent:event]; + if (pause_hit) { + return pause_hit; + } + } + + if (!runtime_model_) { + return nil; + } + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + if (control_index >= resolved_control_frames_.size() || + (!editing_controls_enabled_ && control_index == pause_control_index_)) { + continue; + } + if (TouchControlContainsPoint(controls[control_index], resolved_control_frames_[control_index], + point)) { + return self; + } + } + + return nil; +} + +- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)__unused event { + if (!runtime_model_ || !gameplay_overlay_active_) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + for (UITouch* touch in touches) { + CGPoint point = [touch locationInView:self]; + if (editing_controls_enabled_ && CGRectContainsPoint([self editChromeHeaderDragFrame], point)) { + if (!edit_pinch_active_ && active_captures_.empty()) { + edit_chrome_drag_active_ = YES; + edit_chrome_drag_touch_ = touch; + edit_chrome_drag_frame_ = edit_chrome_.frame; + edit_chrome_drag_touch_offset_ = CGPointMake(point.x - CGRectGetMinX(edit_chrome_.frame), + point.y - CGRectGetMinY(edit_chrome_.frame)); + [self clearEditSnapGuides]; + } + continue; + } + if (editing_controls_enabled_ && edit_pinch_active_) { + continue; + } + if (editing_controls_enabled_ && [self tryBeginEditPinchWithTouch:touch atPoint:point]) { + continue; + } + if (editing_controls_enabled_ && selected_control_index_ != NSNotFound && + CGRectContainsPoint([self selectedControlResizeHandleFrame], point)) { + TouchCaptureState capture; + capture.touch = touch; + capture.control_index = selected_control_index_; + capture.anchor_point = point; + capture.current_point = point; + capture.began_time = CACurrentMediaTime(); + const bool resize_capture_is_portrait = TouchOverlayIsPortraitForView(self); + capture.normalized_frame_at_capture = xe::hid::touch::ActiveControlFrameForOrientation( + controls[capture.control_index], resize_capture_is_portrait); + capture.edit_gesture_mode = TouchCaptureState::EditGestureMode::kResize; + [self beginEditLayoutChangeIfNeeded]; + active_captures_.push_back(capture); + [self clearEditSnapGuides]; + continue; + } + + NSInteger best_control_index = -1; + uint8_t best_priority = 0; + + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + if (control_index >= resolved_control_frames_.size() || + (!editing_controls_enabled_ && control_index == pause_control_index_) || + [self isControlIndexCaptured:control_index]) { + continue; + } + + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + if (!TouchControlContainsPoint(control, resolved_control_frames_[control_index], point)) { + continue; + } + + if (best_control_index < 0 || control.capture_priority > best_priority) { + best_control_index = static_cast(control_index); + best_priority = control.capture_priority; + } + } + + if (best_control_index < 0) { + continue; + } + + TouchCaptureState capture; + capture.touch = touch; + capture.control_index = static_cast(best_control_index); + capture.anchor_point = point; + capture.current_point = point; + capture.began_time = CACurrentMediaTime(); + // Capture the orientation-active frame so subsequent drag math walks + // off the right baseline. The drag commit (updateControlFrameForCapture) + // writes back through MutableActiveControlFrameForOrientation to + // preserve the per-orientation split. + const bool capture_is_portrait = TouchOverlayIsPortraitForView(self); + capture.normalized_frame_at_capture = xe::hid::touch::ActiveControlFrameForOrientation( + controls[capture.control_index], capture_is_portrait); + capture.edit_gesture_mode = TouchCaptureState::EditGestureMode::kMove; + // For Move + D-Pad combos: pin the touch to one of {stick, up, down, left, + // right} at touchesBegan and keep it there for the capture's lifetime. The + // publish path uses this to decide between analog stick output and a + // discrete D-Pad bit. For non-combo controls the helper returns kStick + // (treated as default-MoveStick / no-op for other types). + capture.combo_subzone = TouchComboSubzoneForPoint( + controls[capture.control_index], resolved_control_frames_[capture.control_index], point); + if (editing_controls_enabled_) { + [self clearEditSnapGuides]; + [self setSelectedControlIndex:capture.control_index]; + [self beginEditLayoutChangeIfNeeded]; + } else { + // Press haptic on gameplay captures only — editor drags use the + // selection haptic via setSelectedControlIndex above. Look swipe zones + // are continuous and would buzz constantly, so they do not trigger. + switch (controls[capture.control_index].type) { + case xe::hid::touch::IOSTouchControlType::kActionButton: + case xe::hid::touch::IOSTouchControlType::kPauseButton: + [self playPressHaptic]; + break; + case xe::hid::touch::IOSTouchControlType::kMoveStick: + // D-Pad arrow taps on a combo control feel like a button press, + // not a stick engage. Use the medium press haptic for arrow zones + // and reserve the lighter haptic for the analog stick centre. + if (capture.combo_subzone == TouchCaptureState::ComboSubzone::kDpadUp || + capture.combo_subzone == TouchCaptureState::ComboSubzone::kDpadDown || + capture.combo_subzone == TouchCaptureState::ComboSubzone::kDpadLeft || + capture.combo_subzone == TouchCaptureState::ComboSubzone::kDpadRight) { + [self playPressHaptic]; + } else { + [self playLightPressHaptic]; + } + break; + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + default: + break; + } + } + active_captures_.push_back(capture); + } + + [self publishResolvedState]; +} + +- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)__unused event { + if (!runtime_model_ || !gameplay_overlay_active_) { + return; + } + + const auto& controls = runtime_model_->layout().controls; + const CFTimeInterval current_time = CACurrentMediaTime(); + for (UITouch* touch in touches) { + if (editing_controls_enabled_ && edit_chrome_drag_active_ && touch == edit_chrome_drag_touch_) { + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(self); + if (!safe_area.IsEmpty()) { + CGPoint new_point = [touch locationInView:self]; + edit_chrome_drag_frame_.origin = + CGPointMake(new_point.x - edit_chrome_drag_touch_offset_.x, + new_point.y - edit_chrome_drag_touch_offset_.y); + edit_chrome_drag_frame_ = [self clampedEditChromeFrame:edit_chrome_drag_frame_ + safeArea:safe_area]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + } + continue; + } + if (editing_controls_enabled_ && edit_pinch_active_ && + (touch == edit_pinch_touch_a_ || touch == edit_pinch_touch_b_)) { + [self updatePinchedControlFrame]; + continue; + } + + auto capture_it = + std::find_if(active_captures_.begin(), active_captures_.end(), + [touch](const TouchCaptureState& capture) { return capture.touch == touch; }); + if (capture_it == active_captures_.end() || capture_it->control_index >= controls.size()) { + continue; + } + + CGPoint new_point = [touch locationInView:self]; + if (editing_controls_enabled_) { + [self updateControlFrameForCapture:*capture_it newPoint:new_point]; + capture_it->current_point = new_point; + continue; + } + + const xe::hid::touch::IOSTouchControlDefinition& control = controls[capture_it->control_index]; + TouchCaptureState behavior_capture = *capture_it; + behavior_capture.current_point = new_point; + if (control.type == xe::hid::touch::IOSTouchControlType::kMoveStick && + control.secondary_behavior.trigger == + xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTapForward && + capture_it->control_index < resolved_control_frames_.size() && + !capture_it->secondary_behavior_triggered && + [self hasPendingDoubleTapCandidateForControlIndex:capture_it->control_index + atTime:current_time] && + MoveStickCaptureQualifiesForDoubleTapForward( + control, resolved_control_frames_[capture_it->control_index], behavior_capture, + current_time)) { + capture_it->secondary_behavior_triggered = + [self consumeDoubleTapCandidateForControlIndex:capture_it->control_index + atTime:current_time]; + } + const TouchInteractionBehaviorState secondary_behavior_state = + ResolveTouchInteractionBehaviorState(control.secondary_behavior, behavior_capture, + current_time); + if (capture_it->control_index == look_control_index_ || control.enables_relative_look || + secondary_behavior_state.enables_relative_look) { + const CGPoint delta = CGPointMake(new_point.x - capture_it->current_point.x, + new_point.y - capture_it->current_point.y); + const float look_scale = capture_it->control_index == look_control_index_ + ? std::clamp(control.relative_look_scale, 0.25f, 4.0f) + : (control.enables_relative_look + ? std::clamp(control.relative_look_scale, 0.25f, 4.0f) + : secondary_behavior_state.relative_look_scale); + [self storeLookMotion:SwipeLookVectorForDelta(delta, look_scale) + forControlIndex:capture_it->control_index + atTime:current_time]; + } + capture_it->current_point = new_point; + } + + if (editing_controls_enabled_) { + [self setNeedsLayout]; + [self layoutIfNeeded]; + [self applyCaptureVisualState]; + [self publishResolvedState]; + return; + } + + [self publishResolvedState]; +} + +- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)__unused event { + [self finalizeTouches:touches cancelled:NO]; +} + +- (void)finalizeTouches:(NSSet*)touches cancelled:(BOOL)cancelled { + if (!runtime_model_) { + return; + } + + UITouch* pinch_remaining_touch = nil; + const bool ended_pinch_a = edit_pinch_active_ && [touches containsObject:edit_pinch_touch_a_]; + const bool ended_pinch_b = edit_pinch_active_ && [touches containsObject:edit_pinch_touch_b_]; + if (edit_pinch_active_) { + if (ended_pinch_a && !ended_pinch_b) { + pinch_remaining_touch = edit_pinch_touch_b_; + } else if (ended_pinch_b && !ended_pinch_a) { + pinch_remaining_touch = edit_pinch_touch_a_; + } + } + + const auto& controls = runtime_model_->layout().controls; + const CFTimeInterval current_time = CACurrentMediaTime(); + for (UITouch* touch in touches) { + if (editing_controls_enabled_ && edit_chrome_drag_active_ && touch == edit_chrome_drag_touch_) { + if (!cancelled) { + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(self); + if (!safe_area.IsEmpty()) { + edit_chrome_dock_index_ = + [self nearestEditChromeDockIndexForFrame:edit_chrome_drag_frame_ + safeArea:safe_area + width:CGRectGetWidth(edit_chrome_drag_frame_) + height:CGRectGetHeight(edit_chrome_drag_frame_)]; + [[NSUserDefaults standardUserDefaults] setInteger:edit_chrome_dock_index_ + forKey:@"XeniaTouchEditChromeDock"]; + } + } + [self clearEditChromeDragState]; + [self setNeedsLayout]; + [self layoutIfNeeded]; + continue; + } + auto capture_it = + std::find_if(active_captures_.begin(), active_captures_.end(), + [touch](const TouchCaptureState& capture) { return capture.touch == touch; }); + if (capture_it == active_captures_.end()) { + continue; + } + const CGPoint release_point = [touch locationInView:self]; + capture_it->current_point = release_point; + if (cancelled) { + [self clearLookMotionStateForControlIndex:capture_it->control_index]; + } + if (!cancelled && !editing_controls_enabled_ && capture_it->control_index < controls.size() && + capture_it->control_index < resolved_control_frames_.size()) { + const NSUInteger control_index = capture_it->control_index; + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + const xe::hid::touch::IOSTouchRect& frame = resolved_control_frames_[control_index]; + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kActionButton: { + const bool ended_inside = TouchControlContainsPoint(control, frame, release_point); + if (xe::hid::touch::TouchControlUsesDeferredPrimaryTap(control) && + control_index < recent_action_press_times_.size()) { + const TouchInteractionBehaviorState secondary_behavior_state = + ResolveTouchInteractionBehaviorState(control.secondary_behavior, *capture_it, + current_time); + if (ended_inside && !secondary_behavior_state.active) { + recent_action_press_times_[control_index] = current_time; + } + } + + const bool quick_tap = (current_time - capture_it->began_time) <= + [self doubleTapWindowSecondsForControl:control]; + if (ended_inside && quick_tap && + control.secondary_behavior.trigger == + xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTap && + xe::hid::touch::TouchInteractionBehaviorConfigured(control.secondary_behavior)) { + if (![self consumeDoubleTapCandidateForControlIndex:control_index + atTime:current_time]) { + [self storeDoubleTapCandidateForControlIndex:control_index atTime:current_time]; + } + } + } break; + + case xe::hid::touch::IOSTouchControlType::kMoveStick: { + if (control.secondary_behavior.trigger == + xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTapForward && + xe::hid::touch::TouchInteractionBehaviorConfigured(control.secondary_behavior) && + !capture_it->secondary_behavior_triggered && + MoveStickCaptureQualifiesForDoubleTapForward(control, frame, *capture_it, + current_time)) { + if (![self consumeDoubleTapCandidateForControlIndex:control_index + atTime:current_time]) { + [self storeDoubleTapCandidateForControlIndex:control_index atTime:current_time]; + } + } + } break; + + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + case xe::hid::touch::IOSTouchControlType::kPauseButton: + default: + break; + } + } + active_captures_.erase(capture_it); + } + + if (edit_pinch_active_ && (ended_pinch_a || ended_pinch_b)) { + [self endEditPinchRetainingTouch:(cancelled ? nil : pinch_remaining_touch)]; + } else if (editing_controls_enabled_ && active_captures_.empty()) { + [self clearEditSnapGuides]; + } + + if (editing_controls_enabled_) { + [self finishEditLayoutChangeIfNeeded]; + } + + if (editing_controls_enabled_) { + [self setNeedsLayout]; + [self layoutIfNeeded]; + } + [self publishResolvedState]; +} + +- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)__unused event { + [self finalizeTouches:touches cancelled:YES]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + if (!runtime_model_) { + return; + } + + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(self); + edit_snap_guides_overlay_.frame = self.bounds; + edit_grid_overlay_.frame = CGRectIntegral(CGRectFromTouchRect(xe::hid::touch::IOSTouchRect{ + safe_area.origin_x, safe_area.origin_y, safe_area.width, safe_area.height})); + edit_safe_area_guide_.frame = CGRectIntegral(CGRectFromTouchRect(xe::hid::touch::IOSTouchRect{ + safe_area.origin_x, safe_area.origin_y, safe_area.width, safe_area.height})); + edit_snap_guides_layer_.frame = edit_snap_guides_overlay_.bounds; + edit_grid_dots_layer_.frame = edit_grid_overlay_.bounds; + UIBezierPath* dot_path = [UIBezierPath bezierPath]; + if (CGRectGetWidth(edit_grid_overlay_.bounds) > 0.0 && + CGRectGetHeight(edit_grid_overlay_.bounds) > 0.0) { + for (CGFloat y = kEditGridSpacingPoints * 0.5f; y < CGRectGetHeight(edit_grid_overlay_.bounds); + y += kEditGridSpacingPoints) { + for (CGFloat x = kEditGridSpacingPoints * 0.5f; x < CGRectGetWidth(edit_grid_overlay_.bounds); + x += kEditGridSpacingPoints) { + [dot_path appendPath:[UIBezierPath + bezierPathWithOvalInRect:CGRectMake(x - kEditGridDotRadius, + y - kEditGridDotRadius, + kEditGridDotRadius * 2.0f, + kEditGridDotRadius * 2.0f)]]; + } + } + } + edit_grid_dots_layer_.path = dot_path.CGPath; + + const auto& controls = runtime_model_->layout().controls; + const NSUInteger control_count = + MIN(control_views_.count, static_cast(controls.size())); + resolved_control_frames_.resize(control_count); + pause_button_.hidden = pause_control_index_ == NSNotFound || editing_controls_enabled_; + // Single source of truth for the orientation lookup — every per-frame + // resolve below feeds resolved_control_frames_, which is what hit-testing, + // snap targets, the edit chrome, and publishResolvedState all read. + const bool layout_is_portrait = TouchOverlayIsPortraitForView(self); + const BOOL orientation_flipped = + !last_layout_orientation_known_ || + static_cast(layout_is_portrait) != last_layout_was_portrait_; + last_layout_was_portrait_ = static_cast(layout_is_portrait); + last_layout_orientation_known_ = YES; + for (NSUInteger control_index = 0; control_index < control_count; ++control_index) { + XeniaTouchControlShellView* control_view = [control_views_ objectAtIndex:control_index]; + const xe::hid::touch::IOSTouchControlDefinition& control = controls[control_index]; + const xe::hid::touch::IOSTouchRect& active_frame = + xe::hid::touch::ActiveControlFrameForOrientation(control, layout_is_portrait); + xe::hid::touch::IOSTouchLayoutSpace size_space = + TouchControlSizeSpaceForControlType(self, control.type); + xe::hid::touch::IOSTouchRect frame = + ResolveNormalizedControlFrame(active_frame, safe_area, size_space, control.type); + resolved_control_frames_[control_index] = frame; + control_view.frame = CGRectIntegral(CGRectFromTouchRect(frame)); + if (!editing_controls_enabled_ && control_index == pause_control_index_) { + pause_button_.frame = control_view.frame; + pause_button_.hidden = NO; + } + // Full-screen Look swipe zones go fully invisible during gameplay so the + // player's view of the game isn't obscured. The shell still receives + // touches; only the visible chrome is suppressed. In edit mode the user + // needs to be able to see the zone they're configuring, so keep it + // visible there. + const bool is_fullscreen_look = + control.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone && + active_frame.width >= 0.95f && active_frame.height >= 0.95f; + [control_view setChromeSuppressed:(is_fullscreen_look && !editing_controls_enabled_)]; + } + + const CGFloat chrome_margin = 14.0f; + const BOOL chrome_minimized = edit_chrome_minimized_; + const BOOL showing_layout_library = edit_showing_layout_library_ && !chrome_minimized; + const CGFloat chrome_width = + MIN(safe_area.width - chrome_margin * 2.0f, + chrome_minimized ? 316.0f : (showing_layout_library ? 540.0f : 320.0f)); + const CGFloat chrome_height = [edit_chrome_ preferredHeightForWidth:chrome_width + availableHeight:safe_area.height + margin:chrome_margin]; + edit_chrome_.frame = [self resolvedEditChromeFrameForSafeArea:safe_area + width:chrome_width + height:chrome_height]; + [edit_chrome_ setNeedsLayout]; + + if (chrome_minimized) { + edit_resize_handle_.frame = CGRectIntegral([self selectedControlResizeHandleFrame]); + edit_resize_handle_.hidden = + !editing_controls_enabled_ || selected_control_index_ == NSNotFound; + [self updateConflictHighlights]; + [self updateEditSnapGuidesPath]; + [self applyCaptureVisualState]; + return; + } + + if (showing_layout_library) { + edit_resize_handle_.frame = CGRectZero; + edit_resize_handle_.hidden = YES; + [self updateConflictHighlights]; + [self updateEditSnapGuidesPath]; + [self applyCaptureVisualState]; + return; + } + + edit_resize_handle_.frame = CGRectIntegral([self selectedControlResizeHandleFrame]); + edit_resize_handle_.hidden = !editing_controls_enabled_ || selected_control_index_ == NSNotFound; + + [self updateConflictHighlights]; + [self updateEditSnapGuidesPath]; + [self applyCaptureVisualState]; + + // After rotation, re-sync the edit chrome chip + the More-menu copy-action + // label so they reflect the orientation now being edited. This is cheap + // (UI text + UIMenu rebuild) and only fires when orientation actually + // flips, not on every layout pass. + if (orientation_flipped && editing_controls_enabled_) { + [self refreshEditChromeSelection]; + } +} + +- (void)pauseButtonPressed:(UIButton*)__unused sender { + if (pauseHandler_) { + pauseHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestDoneEditing:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + if (doneEditingHandler_) { + doneEditingHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestSmallerControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self adjustSelectedControlSizeByScale:0.90f]; +} + +- (void)touchOverlayEditChromeDidRequestMatchNearestSize:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self matchSelectedControlSizeToNearestSibling]; +} + +- (void)touchOverlayEditChromeDidRequestLargerControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self adjustSelectedControlSizeByScale:1.10f]; +} + +- (void)touchOverlayEditChromeDidRequestDimmerControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self adjustSelectedControlOpacityByDelta:-0.08f]; +} + +- (void)touchOverlayEditChromeDidRequestBolderControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self adjustSelectedControlOpacityByDelta:0.08f]; +} + +- (void)touchOverlayEditChromeDidRequestCollapse:(XeniaTouchOverlayEditChromeIOS*)__unused chrome { + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(self); + if (!safe_area.IsEmpty()) { + edit_chrome_dock_index_ = + [self nearestEditChromeDockIndexForFrame:edit_chrome_.frame + safeArea:safe_area + width:CGRectGetWidth(edit_chrome_.frame) + height:CGRectGetHeight(edit_chrome_.frame)]; + } + edit_chrome_minimized_ = !edit_chrome_minimized_; + [self clearEditChromeDragState]; + [self refreshEditChromeSelection]; + [self refreshEditPreview]; + [self setNeedsLayout]; + [UIView animateWithDuration:0.16 + animations:^{ + [self layoutIfNeeded]; + }]; +} + +- (void)touchOverlayEditChromeDidRequestCycleAction:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self cycleSelectedControlAction]; +} + +- (void)touchOverlayEditChromeDidRequestRenameLabel:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self presentLabelRenameAlert]; +} + +- (void)touchOverlayEditChromeDidRequestCycleTint:(XeniaTouchOverlayEditChromeIOS*)__unused chrome { + [self cycleSelectedControlTint]; +} + +- (void)touchOverlayEditChromeDidRequestCycleShape:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self cycleSelectedControlShape]; +} + +- (void)touchOverlayEditChromeDidRequestDuplicateControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self duplicateSelectedControl]; +} + +- (void)touchOverlayEditChromeDidRequestUndo:(XeniaTouchOverlayEditChromeIOS*)__unused chrome { + [self undoEditLayoutChange]; +} + +- (void)touchOverlayEditChromeDidRequestRedo:(XeniaTouchOverlayEditChromeIOS*)__unused chrome { + [self redoEditLayoutChange]; +} + +- (void)touchOverlayEditChromeDidRequestToggleGrid:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + edit_grid_enabled_ = !edit_grid_enabled_; + if (editing_controls_enabled_ && edit_grid_enabled_) { + edit_grid_overlay_.hidden = NO; + } + [UIView animateWithDuration:0.12 + animations:^{ + edit_grid_overlay_.alpha = (editing_controls_enabled_ && edit_grid_enabled_) ? 1.0 : 0.0; + } + completion:^(__unused BOOL finished) { + edit_grid_overlay_.hidden = !editing_controls_enabled_ || !edit_grid_enabled_; + }]; + [self refreshEditChromeSelection]; + [self setNeedsLayout]; +} + +- (void)touchOverlayEditChromeDidRequestAddDefaultControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self addNewActionButton]; +} + +- (void)touchOverlayEditChromeDidRequestDeleteControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self deleteSelectedControl]; +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibrary:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + if (layoutLibraryHandler_) { + layoutLibraryHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestHideLayoutLibrary:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self hideLayoutLibrary]; +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibrarySaveCopy: + (XeniaTouchOverlayEditChromeIOS*)__unused chrome { + if (layoutLibrarySaveCopyHandler_) { + layoutLibrarySaveCopyHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibraryRename: + (XeniaTouchOverlayEditChromeIOS*)__unused chrome { + if (layoutLibraryRenameHandler_) { + layoutLibraryRenameHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibraryDelete: + (XeniaTouchOverlayEditChromeIOS*)__unused chrome { + if (layoutLibraryDeleteHandler_) { + layoutLibraryDeleteHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibraryImport: + (XeniaTouchOverlayEditChromeIOS*)__unused chrome { + if (layoutLibraryImportHandler_) { + layoutLibraryImportHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibraryExport: + (XeniaTouchOverlayEditChromeIOS*)__unused chrome { + if (layoutLibraryExportHandler_) { + layoutLibraryExportHandler_(); + } +} + +- (void)touchOverlayEditChromeDidRequestLayoutLibraryReset:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + if (layoutLibraryResetHandler_) { + layoutLibraryResetHandler_(); + } +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestLayoutLibraryLoad:(NSString*)localID { + if (layoutLibraryLoadHandler_) { + layoutLibraryLoadHandler_(localID); + } +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestAction:(xe::hid::touch::IOSTouchAction)action { + [self setSelectedControlAction:action]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestLabelHidden:(BOOL)hidden { + [self setSelectedControlLabelHidden:hidden]; +} + +- (void)touchOverlayEditChromeDidRequestResetLabel:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self resetSelectedControlLabel]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestBehaviorTrigger:(xe::hid::touch::IOSTouchInteractionTrigger)trigger { + [self setSelectedControlBehaviorTrigger:trigger]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestBehaviorAction:(xe::hid::touch::IOSTouchAction)action { + [self setSelectedControlBehaviorAction:action]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestTintStyle:(xe::hid::touch::IOSTouchTintStyle)tintStyle { + [self setSelectedControlTintStyle:tintStyle]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestLookScale:(float)lookScale { + [self setSelectedControlLookScale:lookScale]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestShape:(xe::hid::touch::IOSTouchControlShape)shape { + [self setSelectedControlShape:shape]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestCopyLayoutFromLandscape:(BOOL)fromLandscape { + [self copyAllControlFramesAcrossOrientationsFromLandscape:fromLandscape]; +} + +- (void)touchOverlayEditChromeDidRequestMirrorControl:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self mirrorSelectedControlHorizontally]; +} + +- (void)touchOverlayEditChromeDidRequestToggleMoveDpadRing:(XeniaTouchOverlayEditChromeIOS*)__unused + chrome { + [self toggleSelectedControlMoveDpadRing]; +} + +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)__unused chrome + didRequestAddControlOfType:(xe::hid::touch::IOSTouchControlType)type { + [self addControlOfType:type]; +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h b/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h new file mode 100644 index 000000000..59c6d525d --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h @@ -0,0 +1,31 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_EDITOR_VIEW_CONTROLLER_IOS_H_ +#define XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_EDITOR_VIEW_CONTROLLER_IOS_H_ + +#ifdef __OBJC__ + +#import + +#include + +#import "xenia/ui/ios/shared/ios_view_helpers.h" +#import "xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h" + +@interface XeniaIOSTouchLayoutEditorViewController + : XESheetViewController + +- (instancetype)initWithTitleID:(uint32_t)titleID title:(NSString*)title; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_EDITOR_VIEW_CONTROLLER_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.mm b/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.mm new file mode 100644 index 000000000..6db6fb0dc --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.mm @@ -0,0 +1,287 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_layout_editor_view_controller_ios.h" + +#import + +#include +#include + +#import "xenia/hid/touch/touch_layout_ios.h" +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/touch/touch_controls_overlay_ios.h" + +@implementation XeniaIOSTouchLayoutEditorViewController { + uint32_t title_id_; + NSString* game_title_; + std::unique_ptr runtime_model_; + std::string active_local_id_; + XeniaIOSTouchLayoutUICoordinator* coordinator_; + XeniaTouchControlsOverlayView* overlay_; + BOOL saved_on_dismiss_; +} + +- (instancetype)initWithTitleID:(uint32_t)titleID title:(NSString*)title { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + title_id_ = titleID; + game_title_ = [title copy]; + runtime_model_ = std::make_unique(); + coordinator_ = [[XeniaIOSTouchLayoutUICoordinator alloc] initWithHost:self]; + } + return self; +} + +- (void)dealloc { + [game_title_ release]; + [coordinator_ release]; + [overlay_ release]; + [super dealloc]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Touch Layout"; + if (game_title_.length) { + self.navigationItem.prompt = game_title_; + } + self.view.backgroundColor = [UIColor blackColor]; + self.navigationItem.leftBarButtonItem = + [[[UIBarButtonItem alloc] initWithTitle:@"Close" + style:UIBarButtonItemStylePlain + target:self + action:@selector(doneTapped:)] autorelease]; + self.navigationItem.rightBarButtonItem = + [[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(doneTapped:)] autorelease]; + + overlay_ = [[XeniaTouchControlsOverlayView alloc] initWithRuntimeModel:runtime_model_.get()]; + overlay_.translatesAutoresizingMaskIntoConstraints = NO; + overlay_.backgroundColor = [UIColor clearColor]; + [self.view addSubview:overlay_]; + [NSLayoutConstraint activateConstraints:@[ + [overlay_.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [overlay_.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [overlay_.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [overlay_.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + ]]; + + __block XeniaIOSTouchLayoutEditorViewController* block_self = self; + overlay_.doneEditingHandler = ^{ + [block_self doneTapped:nil]; + }; + overlay_.layoutLibraryHandler = ^{ + [block_self->coordinator_ presentLibrary]; + }; + overlay_.layoutLibraryLoadHandler = ^(NSString* localID) { + [block_self->coordinator_ applyLayoutWithLocalID:localID]; + }; + overlay_.layoutLibrarySaveCopyHandler = ^{ + [block_self->coordinator_ saveCurrentLayoutCopy]; + }; + overlay_.layoutLibraryRenameHandler = ^{ + [block_self->coordinator_ presentRenameSheet]; + }; + overlay_.layoutLibraryDeleteHandler = ^{ + [block_self->coordinator_ presentDeleteSheet]; + }; + overlay_.layoutLibraryImportHandler = ^{ + [block_self->coordinator_ importFromFile]; + }; + overlay_.layoutLibraryExportHandler = ^{ + [block_self->coordinator_ exportCurrentLayout]; + }; + overlay_.layoutLibraryResetHandler = ^{ + [block_self->coordinator_ resetToOfficialPreset]; + }; + + [coordinator_ applyLayoutModelForTitleID:title_id_]; + [overlay_ setGameplayOverlayVisible:YES animated:NO]; + [overlay_ setEditingControlsEnabled:YES animated:NO]; +} + +- (BOOL)prefersStatusBarHidden { + return YES; +} + +- (BOOL)prefersHomeIndicatorAutoHidden { + return YES; +} + +- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { + return UIRectEdgeAll; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + UINavigationController* nav = self.navigationController; + if (!saved_on_dismiss_ && (self.isBeingDismissed || nav.isBeingDismissed || + self.isMovingFromParentViewController)) { + [coordinator_ saveCurrentLayoutForTitleID:title_id_]; + saved_on_dismiss_ = YES; + } +} + +- (void)doneTapped:(id)sender { + (void)sender; + [coordinator_ saveCurrentLayoutForTitleID:title_id_]; + saved_on_dismiss_ = YES; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark - XeniaIOSTouchLayoutUICoordinatorHost + +- (xe::hid::touch::IOSTouchRuntimeModel*)touchLayoutCoordinatorRuntimeModel { + return runtime_model_.get(); +} + +- (uint32_t)touchLayoutCoordinatorActiveTitleID { + return title_id_; +} + +- (std::string)touchLayoutCoordinatorActiveLocalID { + return active_local_id_; +} + +- (void)touchLayoutCoordinatorSetActiveLocalID:(const std::string&)localID { + active_local_id_ = localID; +} + +- (BOOL)touchLayoutCoordinatorGameRunning { + return NO; +} + +- (BOOL)touchLayoutCoordinatorCanPresentPendingInstall { + return self.view.window && + [UIApplication sharedApplication].applicationState == UIApplicationStateActive && + !self.presentedViewController; +} + +- (UIViewController*)touchLayoutCoordinatorTopPresenter { + UIViewController* presenter = self; + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + return presenter; +} + +- (void)touchLayoutCoordinatorSetGameplayModalPresentationPending:(BOOL)pending { + (void)pending; +} + +- (void)touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:(BOOL)animated { + (void)animated; + [overlay_ setGameplayOverlayVisible:YES animated:NO]; +} + +- (void)touchLayoutCoordinatorRefreshTouchOverlayLayoutModel { + [overlay_ refreshLayoutModel]; +} + +- (BOOL)touchLayoutCoordinatorIsShowingLayoutLibrary { + return overlay_ && overlay_.isShowingLayoutLibrary; +} + +- (void)touchLayoutCoordinatorShowLayoutLibraryWithItems: + (NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID { + [overlay_ showLayoutLibraryWithItems:items currentLayoutLocalID:currentLayoutLocalID]; +} + +- (void)touchLayoutCoordinatorSetStatusText:(NSString*)text { + NSString* prompt = text.length ? text : game_title_; + self.navigationItem.prompt = prompt; + if (!text.length) { + return; + } + NSString* captured = [text copy]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if ([self.navigationItem.prompt isEqualToString:captured]) { + self.navigationItem.prompt = game_title_; + } + [captured release]; + }); +} + +- (void)touchLayoutCoordinatorPresentAlertWithTitle:(NSString*)title message:(NSString*)message { + XEPresentOKAlert(self, title, message); +} + +- (void)touchLayoutCoordinatorPresentKeyboardPromptWithTitle:(NSString*)title + description:(NSString*)description + defaultText:(NSString*)defaultText + completion:(void (^)(BOOL cancelled, + NSString* text))completion { + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:title.length ? title : @"Input Required" + message:description + preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField* text_field) { + text_field.text = defaultText ?: @""; + text_field.autocapitalizationType = UITextAutocapitalizationTypeNone; + text_field.autocorrectionType = UITextAutocorrectionTypeNo; + text_field.clearButtonMode = UITextFieldViewModeWhileEditing; + }]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:^(__unused UIAlertAction* action) { + if (completion) { + completion(YES, @""); + } + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + UITextField* text_field = alert.textFields.firstObject; + if (completion) { + completion(NO, text_field.text ?: @""); + } + }]]; + [[self touchLayoutCoordinatorTopPresenter] presentViewController:alert animated:YES completion:nil]; +} + +- (void)touchLayoutCoordinatorOpenTouchLayoutFileImportPicker { + UIDocumentPickerViewController* picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[ UTTypeData ]]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + picker.shouldShowFileExtensions = YES; + [[self touchLayoutCoordinatorTopPresenter] presentViewController:picker animated:YES completion:nil]; + [picker release]; +} + +- (void)touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded { +} + +#pragma mark - UIDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController*)controller + didPickDocumentsAtURLs:(NSArray*)urls { + (void)controller; + NSURL* url = urls.firstObject; + if (!url) { + return; + } + BOOL access_granted = [url startAccessingSecurityScopedResource]; + [coordinator_ importLayoutAtURL:url]; + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController*)controller { + (void)controller; + [self touchLayoutCoordinatorSetStatusText:@"Touch layout import cancelled."]; +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.h b/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.h new file mode 100644 index 000000000..f06074c86 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.h @@ -0,0 +1,33 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_CONTROLLER_IOS_H_ +#define XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_CONTROLLER_IOS_H_ + +#import + +#import "xenia/ui/ios/touch/touch_layout_library_view_ios.h" + +@interface XeniaTouchLayoutLibraryTableController + : NSObject + +@property(nonatomic, copy) void (^loadHandler)(NSString* localID); +@property(nonatomic, copy) void (^saveCopyHandler)(void); +@property(nonatomic, copy) void (^renameHandler)(void); +@property(nonatomic, copy) void (^deleteHandler)(void); +@property(nonatomic, copy) void (^importHandler)(void); +@property(nonatomic, copy) void (^exportHandler)(void); +@property(nonatomic, copy) void (^resetHandler)(void); + +- (void)setItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID; + +@end + +#endif // XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_CONTROLLER_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.mm b/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.mm new file mode 100644 index 000000000..46ca832ea --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_controller_ios.mm @@ -0,0 +1,312 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_layout_library_controller_ios.h" + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_theme_controls.h" + +typedef NS_ENUM(NSInteger, XeniaTouchLayoutLibrarySection) { + kXeniaTouchLayoutLibrarySectionActions = 0, + kXeniaTouchLayoutLibrarySectionManage, + kXeniaTouchLayoutLibrarySectionLayouts, +}; + +typedef NS_ENUM(NSInteger, XeniaTouchLayoutLibraryActionRow) { + kXeniaTouchLayoutLibraryActionSaveCopy = 0, + kXeniaTouchLayoutLibraryActionImport, + kXeniaTouchLayoutLibraryActionExport, + kXeniaTouchLayoutLibraryActionReset, +}; + +typedef NS_ENUM(NSInteger, XeniaTouchLayoutLibraryManageRow) { + kXeniaTouchLayoutLibraryManageRename = 0, + kXeniaTouchLayoutLibraryManageDelete, +}; + +@implementation XeniaTouchLayoutLibraryTableController { + NSArray* items_; + NSString* current_layout_local_id_; +} + +@synthesize loadHandler = loadHandler_; +@synthesize saveCopyHandler = saveCopyHandler_; +@synthesize renameHandler = renameHandler_; +@synthesize deleteHandler = deleteHandler_; +@synthesize importHandler = importHandler_; +@synthesize exportHandler = exportHandler_; +@synthesize resetHandler = resetHandler_; + +- (void)dealloc { + [resetHandler_ release]; + [exportHandler_ release]; + [importHandler_ release]; + [deleteHandler_ release]; + [renameHandler_ release]; + [saveCopyHandler_ release]; + [loadHandler_ release]; + [current_layout_local_id_ release]; + [items_ release]; + [super dealloc]; +} + +- (void)setItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID { + [items_ release]; + items_ = [items copy]; + [current_layout_local_id_ release]; + current_layout_local_id_ = [currentLayoutLocalID copy]; +} + +- (BOOL)hasSavedLayoutLibraryItems { + for (XeniaTouchLayoutLibraryItem* item in items_) { + if (!item.official) { + return YES; + } + } + return NO; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView* __unused)tableView { + return 3; +} + +- (NSInteger)tableView:(UITableView* __unused)tableView numberOfRowsInSection:(NSInteger)section { + switch (section) { + case kXeniaTouchLayoutLibrarySectionActions: + return 4; + case kXeniaTouchLayoutLibrarySectionManage: + return 2; + case kXeniaTouchLayoutLibrarySectionLayouts: + return static_cast(items_.count); + default: + return 0; + } +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForHeaderInSection:(NSInteger)section { + switch (section) { + case kXeniaTouchLayoutLibrarySectionActions: + return @"Actions"; + case kXeniaTouchLayoutLibrarySectionManage: + return @"Saved Layouts"; + case kXeniaTouchLayoutLibrarySectionLayouts: + return @"Browse"; + default: + return nil; + } +} + +- (NSString*)tableView:(UITableView* __unused)tableView titleForFooterInSection:(NSInteger)section { + (void)section; + return nil; +} + +- (CGFloat)tableView:(UITableView* __unused)tableView + heightForHeaderInSection:(NSInteger)__unused section { + return 24.0f; +} + +- (CGFloat)tableView:(UITableView* __unused)tableView + heightForFooterInSection:(NSInteger)__unused section { + return 0.01f; +} + +- (UIView*)tableView:(UITableView*)tableView viewForHeaderInSection:(NSInteger)section { + NSString* title = [self tableView:tableView titleForHeaderInSection:section]; + if (!title.length) { + return nil; + } + + UIView* container = [[[UIView alloc] initWithFrame:CGRectZero] autorelease]; + container.backgroundColor = [UIColor clearColor]; + + UILabel* label = [[[UILabel alloc] initWithFrame:CGRectZero] autorelease]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.text = title; + label.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.56]; + xe_apply_label_font(label, UIFontTextStyleCaption1, 12.0, + UIFontWeightSemibold); + [container addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:container.leadingAnchor constant:18.0f], + [label.trailingAnchor constraintEqualToAnchor:container.trailingAnchor constant:-18.0f], + [label.bottomAnchor constraintEqualToAnchor:container.bottomAnchor constant:-4.0f], + ]]; + + return container; +} + +- (UITableViewCell*)tableView:(UITableView*)tableView + cellForRowAtIndexPath:(NSIndexPath*)indexPath { + static NSString* const kTouchLayoutLibraryCellIdentifier = @"XeniaTouchLayoutLibraryCell"; + XeniaTouchLayoutLibraryRowCell* cell = + [tableView dequeueReusableCellWithIdentifier:kTouchLayoutLibraryCellIdentifier]; + if (!cell) { + cell = [[[XeniaTouchLayoutLibraryRowCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kTouchLayoutLibraryCellIdentifier] + autorelease]; + } + + cell.overlayTitleLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.96]; + cell.overlaySubtitleLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.64]; + cell.overlayTitleLabel.text = @""; + cell.overlaySubtitleLabel.text = @""; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + [cell setShowsDisclosure:NO]; + [cell setShowsCheckmark:NO]; + [cell setThumbnailImage:nil]; + [cell setShowsDefaultBadge:NO]; + + switch (indexPath.section) { + case kXeniaTouchLayoutLibrarySectionActions: + switch (indexPath.row) { + case kXeniaTouchLayoutLibraryActionSaveCopy: + cell.overlayTitleLabel.text = @"Save Layout Copy"; + cell.overlaySubtitleLabel.text = @"Create a reusable local copy from the current layout."; + break; + case kXeniaTouchLayoutLibraryActionImport: + cell.overlayTitleLabel.text = @"Import Layout File"; + cell.overlaySubtitleLabel.text = + @"Load a `.touchlayout.toml` file into the current title."; + break; + case kXeniaTouchLayoutLibraryActionExport: + cell.overlayTitleLabel.text = @"Export Current Layout"; + cell.overlaySubtitleLabel.text = @"Share the current title-local touch layout as a file."; + break; + case kXeniaTouchLayoutLibraryActionReset: + cell.overlayTitleLabel.text = @"Reset to Official Preset"; + cell.overlaySubtitleLabel.text = + @"Restore the current title to its official base preset."; + // "Reset" is a destructive-but-recoverable action — warning amber + // rather than danger red. + cell.overlayTitleLabel.textColor = + [[XeniaTheme statusWarning] colorWithAlphaComponent:0.98]; + break; + default: + break; + } + [cell setShowsDisclosure:YES]; + break; + + case kXeniaTouchLayoutLibrarySectionManage: { + const BOOL enabled = [self hasSavedLayoutLibraryItems]; + cell.overlayTitleLabel.text = indexPath.row == kXeniaTouchLayoutLibraryManageRename + ? @"Rename Saved Layout" + : @"Delete Saved Layout"; + cell.overlaySubtitleLabel.text = indexPath.row == kXeniaTouchLayoutLibraryManageRename + ? @"Rename one of your saved local layout copies." + : @"Delete one of your saved local layout copies."; + cell.selectionStyle = + enabled ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + [cell setShowsDisclosure:enabled]; + if (!enabled) { + cell.overlayTitleLabel.textColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacityHeavy]]; + cell.overlaySubtitleLabel.textColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacityStrong]]; + } else if (indexPath.row == kXeniaTouchLayoutLibraryManageDelete) { + cell.overlayTitleLabel.textColor = + [[XeniaTheme statusError] colorWithAlphaComponent:0.98]; + } + break; + } + + case kXeniaTouchLayoutLibrarySectionLayouts: { + if (indexPath.row >= 0 && indexPath.row < static_cast(items_.count)) { + XeniaTouchLayoutLibraryItem* item = [items_ objectAtIndex:indexPath.row]; + cell.overlayTitleLabel.text = item.displayName ?: item.localID; + NSMutableArray* subtitle_parts = [NSMutableArray array]; + [subtitle_parts addObject:(item.official ? @"Official preset" : @"Saved local copy")]; + if (item.author.length > 0) { + [subtitle_parts addObject:item.author]; + } + if (item.isDefaultForCurrentTitle) { + [subtitle_parts addObject:@"Default for this game"]; + } + cell.overlaySubtitleLabel.text = [subtitle_parts componentsJoinedByString:@" • "]; + [cell setShowsCheckmark:[item.localID isEqualToString:current_layout_local_id_]]; + [cell setThumbnailImage:item.thumbnail]; + [cell setShowsDefaultBadge:item.isDefaultForCurrentTitle]; + } + break; + } + default: + break; + } + + return cell; +} + +- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + switch (indexPath.section) { + case kXeniaTouchLayoutLibrarySectionActions: + switch (indexPath.row) { + case kXeniaTouchLayoutLibraryActionSaveCopy: + if (saveCopyHandler_) { + saveCopyHandler_(); + } + break; + case kXeniaTouchLayoutLibraryActionImport: + if (importHandler_) { + importHandler_(); + } + break; + case kXeniaTouchLayoutLibraryActionExport: + if (exportHandler_) { + exportHandler_(); + } + break; + case kXeniaTouchLayoutLibraryActionReset: + if (resetHandler_) { + resetHandler_(); + } + break; + default: + break; + } + break; + + case kXeniaTouchLayoutLibrarySectionManage: + if (![self hasSavedLayoutLibraryItems]) { + return; + } + if (indexPath.row == kXeniaTouchLayoutLibraryManageRename) { + if (renameHandler_) { + renameHandler_(); + } + } else if (indexPath.row == kXeniaTouchLayoutLibraryManageDelete) { + if (deleteHandler_) { + deleteHandler_(); + } + } + break; + + case kXeniaTouchLayoutLibrarySectionLayouts: + if (indexPath.row < 0 || indexPath.row >= static_cast(items_.count)) { + return; + } + if (loadHandler_) { + XeniaTouchLayoutLibraryItem* item = [items_ objectAtIndex:indexPath.row]; + [current_layout_local_id_ release]; + current_layout_local_id_ = [item.localID copy]; + loadHandler_(item.localID); + [tableView reloadData]; + } + break; + + default: + break; + } +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_layout_library_ios.h b/src/xenia/ui/ios/touch/touch_layout_library_ios.h new file mode 100644 index 000000000..3d0e7b880 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_ios.h @@ -0,0 +1,60 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_IOS_H_ +#define XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_IOS_H_ + +#import + +#include +#include +#include +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace ui { + +inline constexpr const char* kOfficialTouchLayoutLocalID = "fps_standard"; +inline constexpr const char* kOfficialTouchLayoutExpandedLocalID = "fps_expanded"; +inline constexpr const char* kOfficialTouchLayoutMirroredLocalID = "fps_mirrored"; +inline constexpr const char* kTouchLayoutAssignmentSection = "TouchLayoutAssignment"; + +struct IOSTouchLocalLayoutInfo { + std::filesystem::path path; + std::string local_id; + std::string display_name; + std::string author; + bool official = false; + // Cached layout content. Loaded by availableLocalTouchLayouts so the + // library populator can render thumbnails without re-reading from disk. + xe::hid::touch::IOSTouchLayoutModel layout; +}; + +bool IsOfficialTouchLayoutLocalID(const std::string& local_id); +size_t OfficialTouchLayoutPresetSortOrder(const std::string& local_id); +std::string NormalizeOfficialTouchLayoutBaseTemplate(std::string base_template); +std::string MakeTouchLayoutSlug(std::string value); +bool TryNormalizeConfiguredTouchLayoutLocalID(const std::string& configured_local_id, + std::string* normalized_local_id_out); +std::string TouchLayoutBaseTemplateForTable(const toml::table& table); +xe::hid::touch::IOSTouchLayoutModel MakeTouchLayoutSeedModelForTable(const toml::table& table); +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSTouchLayoutModelForLocalID( + const std::string& local_id); +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSTouchLayoutModel(); +UIImage* RenderTouchLayoutThumbnail(const xe::hid::touch::IOSTouchLayoutModel& layout, CGSize size); +std::string ReadTitleTouchLayoutAssignment(uint32_t title_id); +bool TouchLayoutContentMatches(const xe::hid::touch::IOSTouchLayoutModel& a, + const xe::hid::touch::IOSTouchLayoutModel& b); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_library_ios.mm b/src/xenia/ui/ios/touch/touch_layout_library_ios.mm new file mode 100644 index 000000000..d496845fe --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_ios.mm @@ -0,0 +1,292 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/touch/touch_layout_library_ios.h" + +#include +#include +#include +#include +#include + +#include "xenia/config.h" +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +namespace xe { +namespace ui { +namespace { + +struct IOSTouchOfficialLayoutPreset { + const char* local_id = nullptr; + const char* display_name = nullptr; + xe::hid::touch::IOSTouchLayoutModel (*factory)() = nullptr; +}; + +xe::hid::touch::IOSTouchRect MirrorTouchLayoutRectHorizontally( + const xe::hid::touch::IOSTouchRect& rect) { + return xe::hid::touch::IOSTouchRect{1.0f - rect.x - rect.width, rect.y, + rect.width, rect.height}; +} + +xe::hid::touch::IOSTouchControlDefinition MakeOfficialTouchActionButton( + const char* identifier, xe::hid::touch::IOSTouchAction action, + const xe::hid::touch::IOSTouchRect& frame, uint8_t capture_priority, + xe::hid::touch::IOSTouchTintStyle tint_style = + xe::hid::touch::IOSTouchTintStyle::kAuto) { + xe::hid::touch::IOSTouchControlDefinition control; + control.identifier = identifier; + control.type = xe::hid::touch::IOSTouchControlType::kActionButton; + control.shape = xe::hid::touch::IOSTouchControlShape::kCircle; + control.normalized_frame = frame; + control.activation_radius = 0.5f; + control.visual_opacity = 0.92f; + control.capture_priority = capture_priority; + control.tint_style = tint_style; + xe::hid::touch::ConfigureIOSTouchControlAction(action, &control); + return control; +} + +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSFPSStandardLayoutModel() { + xe::hid::touch::IOSTouchLayoutModel layout = + xe::hid::touch::CreateDefaultIOSFPSLayoutModel(); + layout.layout_id = kOfficialTouchLayoutLocalID; + layout.display_name = "FPS Standard"; + layout.author = "XeniOS"; + layout.base_template = kOfficialTouchLayoutLocalID; + return layout; +} + +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSFPSExpandedLayoutModel() { + xe::hid::touch::IOSTouchLayoutModel layout = + MakeOfficialIOSFPSStandardLayoutModel(); + layout.layout_id = kOfficialTouchLayoutExpandedLocalID; + layout.display_name = "FPS Expanded"; + layout.base_template = kOfficialTouchLayoutExpandedLocalID; + layout.controls.push_back(MakeOfficialTouchActionButton( + "swap_button", xe::hid::touch::IOSTouchAction::kButtonY, + xe::hid::touch::IOSTouchRect{0.84f, 0.36f, 0.11f, 0.13f}, 235, + xe::hid::touch::IOSTouchTintStyle::kAmber)); + layout.controls.push_back(MakeOfficialTouchActionButton( + "melee_button", xe::hid::touch::IOSTouchAction::kButtonB, + xe::hid::touch::IOSTouchRect{0.88f, 0.66f, 0.11f, 0.13f}, 237, + xe::hid::touch::IOSTouchTintStyle::kRose)); + layout.controls.push_back(MakeOfficialTouchActionButton( + "left_bumper_button", xe::hid::touch::IOSTouchAction::kLeftBumper, + xe::hid::touch::IOSTouchRect{0.58f, 0.49f, 0.11f, 0.13f}, 234, + xe::hid::touch::IOSTouchTintStyle::kSky)); + layout.controls.push_back(MakeOfficialTouchActionButton( + "right_bumper_button", xe::hid::touch::IOSTouchAction::kRightBumper, + xe::hid::touch::IOSTouchRect{0.72f, 0.31f, 0.11f, 0.13f}, 233, + xe::hid::touch::IOSTouchTintStyle::kSky)); + return layout; +} + +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSFPSMirroredLayoutModel() { + xe::hid::touch::IOSTouchLayoutModel layout = + MakeOfficialIOSFPSStandardLayoutModel(); + layout.layout_id = kOfficialTouchLayoutMirroredLocalID; + layout.display_name = "FPS Mirrored"; + layout.base_template = kOfficialTouchLayoutMirroredLocalID; + for (auto& control : layout.controls) { + control.normalized_frame = + MirrorTouchLayoutRectHorizontally(control.normalized_frame); + } + return layout; +} + +const IOSTouchOfficialLayoutPreset kOfficialTouchLayoutPresets[] = { + {kOfficialTouchLayoutLocalID, "FPS Standard", + &MakeOfficialIOSFPSStandardLayoutModel}, + {kOfficialTouchLayoutExpandedLocalID, "FPS Expanded", + &MakeOfficialIOSFPSExpandedLayoutModel}, + {kOfficialTouchLayoutMirroredLocalID, "FPS Mirrored", + &MakeOfficialIOSFPSMirroredLayoutModel}, +}; + +const IOSTouchOfficialLayoutPreset* FindOfficialTouchLayoutPreset( + const std::string& local_id) { + for (const auto& preset : kOfficialTouchLayoutPresets) { + if (local_id == preset.local_id) { + return &preset; + } + } + return nullptr; +} + +} // namespace + +bool IsOfficialTouchLayoutLocalID(const std::string& local_id) { + return FindOfficialTouchLayoutPreset(local_id) != nullptr; +} + +size_t OfficialTouchLayoutPresetSortOrder(const std::string& local_id) { + for (size_t index = 0; index < std::size(kOfficialTouchLayoutPresets); + ++index) { + if (local_id == kOfficialTouchLayoutPresets[index].local_id) { + return index; + } + } + return std::size(kOfficialTouchLayoutPresets); +} + +std::string NormalizeOfficialTouchLayoutBaseTemplate( + std::string base_template) { + if (FindOfficialTouchLayoutPreset(base_template)) { + return base_template; + } + return kOfficialTouchLayoutLocalID; +} + +std::string MakeTouchLayoutSlug(std::string value) { + std::string slug; + slug.reserve(value.size()); + bool last_was_separator = false; + for (char c : value) { + if (std::isalnum(static_cast(c))) { + slug.push_back( + static_cast(std::tolower(static_cast(c)))); + last_was_separator = false; + continue; + } + if (!last_was_separator && !slug.empty()) { + slug.push_back('_'); + last_was_separator = true; + } + } + while (!slug.empty() && slug.back() == '_') { + slug.pop_back(); + } + return slug.empty() ? "touch_layout" : slug; +} + +bool TryNormalizeConfiguredTouchLayoutLocalID( + const std::string& configured_local_id, + std::string* normalized_local_id_out) { + if (!normalized_local_id_out || configured_local_id.empty()) { + return false; + } + if (configured_local_id.find('/') != std::string::npos || + configured_local_id.find('\\') != std::string::npos || + configured_local_id.find("..") != std::string::npos) { + return false; + } + std::string normalized_local_id = MakeTouchLayoutSlug(configured_local_id); + if (normalized_local_id.empty() || + normalized_local_id != configured_local_id) { + return false; + } + *normalized_local_id_out = std::move(normalized_local_id); + return true; +} + +std::string TouchLayoutBaseTemplateForTable(const toml::table& table) { + if (auto base_template = table["base_template"].value()) { + return NormalizeOfficialTouchLayoutBaseTemplate(*base_template); + } + if (auto layout_id = table["layout_id"].value()) { + return NormalizeOfficialTouchLayoutBaseTemplate(*layout_id); + } + return kOfficialTouchLayoutLocalID; +} + +xe::hid::touch::IOSTouchLayoutModel MakeTouchLayoutSeedModelForTable( + const toml::table& table) { + return MakeOfficialIOSTouchLayoutModelForLocalID( + TouchLayoutBaseTemplateForTable(table)); +} + +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSTouchLayoutModelForLocalID( + const std::string& local_id) { + if (const auto* preset = FindOfficialTouchLayoutPreset(local_id)) { + return preset->factory(); + } + return MakeOfficialIOSFPSStandardLayoutModel(); +} + +xe::hid::touch::IOSTouchLayoutModel MakeOfficialIOSTouchLayoutModel() { + return MakeOfficialIOSTouchLayoutModelForLocalID( + kOfficialTouchLayoutLocalID); +} + +UIImage* RenderTouchLayoutThumbnail( + const xe::hid::touch::IOSTouchLayoutModel& layout, CGSize size) { + if (size.width <= 0.0 || size.height <= 0.0) { + return nil; + } + UIGraphicsImageRendererFormat* format = + [UIGraphicsImageRendererFormat preferredFormat]; + format.opaque = NO; + format.scale = 0.0; + UIGraphicsImageRenderer* renderer = + [[[UIGraphicsImageRenderer alloc] initWithSize:size format:format] + autorelease]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext* ctx) { + CGContextRef cg = ctx.CGContext; + CGContextSetFillColorWithColor( + cg, [UIColor colorWithWhite:0.10 alpha:0.95].CGColor); + CGContextFillRect(cg, CGRectMake(0, 0, size.width, size.height)); + + for (const auto& control : layout.controls) { + const CGRect frame = CGRectMake( + control.normalized_frame.x * size.width, + control.normalized_frame.y * size.height, + MAX(control.normalized_frame.width * size.width, 2.0), + MAX(control.normalized_frame.height * size.height, 2.0)); + UIColor* tint = + XeniaTouchOverlayAccentColor(control.tint_style, control.type); + CGContextSetFillColorWithColor( + cg, [tint colorWithAlphaComponent:0.55].CGColor); + CGContextSetStrokeColorWithColor(cg, tint.CGColor); + CGContextSetLineWidth(cg, 0.5); + const CGFloat corner = + control.shape == xe::hid::touch::IOSTouchControlShape::kCircle + ? MIN(frame.size.width, frame.size.height) * 0.5 + : MIN(MIN(frame.size.width, frame.size.height) * 0.30, 4.0); + UIBezierPath* path = + [UIBezierPath bezierPathWithRoundedRect:frame cornerRadius:corner]; + [path fill]; + [path stroke]; + } + }]; +} + +std::string ReadTitleTouchLayoutAssignment(uint32_t title_id) { + if (!title_id) { + return std::string(); + } + toml::table config = config::LoadGameConfig(title_id); + const toml::table* assignment = + config[kTouchLayoutAssignmentSection].as_table(); + if (!assignment) { + return std::string(); + } + auto local_layout_id = (*assignment)["local_layout_id"].value(); + if (!local_layout_id) { + return std::string(); + } + std::string normalized; + if (!TryNormalizeConfiguredTouchLayoutLocalID(*local_layout_id, + &normalized)) { + return std::string(); + } + return normalized; +} + +bool TouchLayoutContentMatches( + const xe::hid::touch::IOSTouchLayoutModel& a, + const xe::hid::touch::IOSTouchLayoutModel& b) { + std::ostringstream stream_a; + std::ostringstream stream_b; + stream_a << xe::hid::touch::EncodeIOSTouchLayoutModel(a); + stream_b << xe::hid::touch::EncodeIOSTouchLayoutModel(b); + return stream_a.str() == stream_b.str(); +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/touch/touch_layout_library_view_ios.h b/src/xenia/ui/ios/touch/touch_layout_library_view_ios.h new file mode 100644 index 000000000..a620baff1 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_view_ios.h @@ -0,0 +1,40 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_VIEW_IOS_H_ +#define XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_VIEW_IOS_H_ + +#import + +@interface XeniaTouchLayoutLibraryItem : NSObject + +@property(nonatomic, copy) NSString* localID; +@property(nonatomic, copy) NSString* displayName; +@property(nonatomic, copy) NSString* author; +@property(nonatomic, assign) BOOL official; +// Pre-rendered miniature preview of this layout's controls. Optional; the +// library cell falls back to a neutral icon when nil. +@property(nonatomic, strong) UIImage* thumbnail; +// True if this layout is the currently bound default for the active game title. +@property(nonatomic, assign) BOOL isDefaultForCurrentTitle; + +@end + +@interface XeniaTouchLayoutLibraryRowCell : UITableViewCell + +- (UILabel*)overlayTitleLabel; +- (UILabel*)overlaySubtitleLabel; +- (void)setShowsDisclosure:(BOOL)showsDisclosure; +- (void)setShowsCheckmark:(BOOL)showsCheckmark; +- (void)setThumbnailImage:(UIImage*)thumbnail; +- (void)setShowsDefaultBadge:(BOOL)showsDefaultBadge; + +@end + +#endif // XENIA_UI_IOS_TOUCH_LAYOUT_LIBRARY_VIEW_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_library_view_ios.mm b/src/xenia/ui/ios/touch/touch_layout_library_view_ios.mm new file mode 100644 index 000000000..c7dbbc16d --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_library_view_ios.mm @@ -0,0 +1,226 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_layout_library_view_ios.h" + +#import "xenia/ui/ios/shared/ios_theme.h" +#import "xenia/ui/ios/shared/ios_theme_controls.h" + +@implementation XeniaTouchLayoutLibraryItem + +@synthesize localID = _localID; +@synthesize displayName = _displayName; +@synthesize author = _author; +@synthesize official = _official; +@synthesize thumbnail = _thumbnail; +@synthesize isDefaultForCurrentTitle = _isDefaultForCurrentTitle; + +- (void)dealloc { + [_localID release]; + [_displayName release]; + [_author release]; + [_thumbnail release]; + [super dealloc]; +} + +@end + +@implementation XeniaTouchLayoutLibraryRowCell { + UILabel* overlayTitleLabel_; + UILabel* overlaySubtitleLabel_; + UIImageView* overlayAccessoryView_; + UIImageView* thumbnailImageView_; + UIImageView* defaultBadgeView_; + BOOL showsDisclosure_; + BOOL showsCheckmark_; + BOOL showsDefaultBadge_; +} + +- (instancetype)initWithStyle:(UITableViewCellStyle)style + reuseIdentifier:(NSString*)reuseIdentifier { + if (!(self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) { + return nil; + } + + self.backgroundColor = [UIColor clearColor]; + self.contentView.backgroundColor = [UIColor clearColor]; + + UIView* card_view = [[[UIView alloc] initWithFrame:CGRectZero] autorelease]; + card_view.backgroundColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySubtle]]; + card_view.layer.cornerRadius = XeniaRadiusLg; + card_view.layer.masksToBounds = YES; + card_view.tag = 1; + [self.contentView addSubview:card_view]; + + UIView* selected_background = + [[[UIView alloc] initWithFrame:CGRectZero] autorelease]; + selected_background.backgroundColor = [UIColor clearColor]; + UIView* selected_card = + [[[UIView alloc] initWithFrame:CGRectZero] autorelease]; + selected_card.backgroundColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]]; + selected_card.layer.cornerRadius = XeniaRadiusLg; + selected_card.layer.masksToBounds = YES; + selected_card.tag = 1; + [selected_background addSubview:selected_card]; + self.selectedBackgroundView = selected_background; + + overlayTitleLabel_ = [[UILabel alloc] initWithFrame:CGRectZero]; + overlayTitleLabel_.backgroundColor = [UIColor clearColor]; + xe_apply_label_font(overlayTitleLabel_, UIFontTextStyleSubheadline, 15.0, + UIFontWeightSemibold); + overlayTitleLabel_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.96]; + overlayTitleLabel_.numberOfLines = 1; + [card_view addSubview:overlayTitleLabel_]; + + overlaySubtitleLabel_ = [[UILabel alloc] initWithFrame:CGRectZero]; + overlaySubtitleLabel_.backgroundColor = [UIColor clearColor]; + xe_apply_label_font(overlaySubtitleLabel_, UIFontTextStyleCaption1, 12.0, + UIFontWeightRegular); + overlaySubtitleLabel_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.64]; + overlaySubtitleLabel_.numberOfLines = 1; + overlaySubtitleLabel_.lineBreakMode = NSLineBreakByTruncatingTail; + [card_view addSubview:overlaySubtitleLabel_]; + + overlayAccessoryView_ = [[UIImageView alloc] initWithFrame:CGRectZero]; + overlayAccessoryView_.contentMode = UIViewContentModeScaleAspectFit; + overlayAccessoryView_.tintColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacityHeavy]]; + [card_view addSubview:overlayAccessoryView_]; + + thumbnailImageView_ = [[UIImageView alloc] initWithFrame:CGRectZero]; + thumbnailImageView_.contentMode = UIViewContentModeScaleAspectFit; + thumbnailImageView_.layer.cornerRadius = XeniaRadiusMd; + thumbnailImageView_.layer.masksToBounds = YES; + thumbnailImageView_.backgroundColor = + [[UIColor blackColor] colorWithAlphaComponent:[XeniaTheme opacityStrong]]; + thumbnailImageView_.hidden = YES; + [card_view addSubview:thumbnailImageView_]; + + defaultBadgeView_ = [[UIImageView alloc] initWithFrame:CGRectZero]; + defaultBadgeView_.image = [UIImage systemImageNamed:@"star.fill"]; + defaultBadgeView_.tintColor = + [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.95]; + defaultBadgeView_.contentMode = UIViewContentModeScaleAspectFit; + defaultBadgeView_.hidden = YES; + [card_view addSubview:defaultBadgeView_]; + return self; +} + +- (void)dealloc { + [defaultBadgeView_ release]; + [thumbnailImageView_ release]; + [overlayAccessoryView_ release]; + [overlaySubtitleLabel_ release]; + [overlayTitleLabel_ release]; + [super dealloc]; +} + +- (UILabel*)overlayTitleLabel { + return overlayTitleLabel_; +} + +- (UILabel*)overlaySubtitleLabel { + return overlaySubtitleLabel_; +} + +- (void)setShowsDisclosure:(BOOL)showsDisclosure { + showsDisclosure_ = showsDisclosure; + [self setNeedsLayout]; +} + +- (void)setShowsCheckmark:(BOOL)showsCheckmark { + showsCheckmark_ = showsCheckmark; + [self setNeedsLayout]; +} + +- (void)setThumbnailImage:(UIImage*)thumbnail { + thumbnailImageView_.image = thumbnail; + thumbnailImageView_.hidden = thumbnail == nil; + [self setNeedsLayout]; +} + +- (void)setShowsDefaultBadge:(BOOL)showsDefaultBadge { + showsDefaultBadge_ = showsDefaultBadge; + defaultBadgeView_.hidden = !showsDefaultBadge; + [self setNeedsLayout]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + CGRect card_frame = CGRectInset(self.bounds, 0.0f, 4.0f); + UIView* card_view = [self.contentView viewWithTag:1]; + card_view.frame = card_frame; + self.selectedBackgroundView.frame = self.bounds; + UIView* selected_card = [self.selectedBackgroundView viewWithTag:1]; + selected_card.frame = card_frame; + + const CGFloat horizontal_inset = 16.0f; + const CGFloat accessory_size = 18.0f; + const CGFloat accessory_gap = 12.0f; + const CGFloat thumbnail_width = 60.0f; + const CGFloat thumbnail_height = 34.0f; + const CGFloat thumbnail_gap = 12.0f; + const CGFloat default_badge_size = 14.0f; + const CGFloat default_badge_gap = 6.0f; + + const BOOL has_thumbnail = + !thumbnailImageView_.hidden && thumbnailImageView_.image != nil; + const BOOL shows_accessory = showsDisclosure_ || showsCheckmark_; + const CGFloat leading_inset = + horizontal_inset + + (has_thumbnail ? (thumbnail_width + thumbnail_gap) : 0.0f); + const CGFloat accessory_area_width = + shows_accessory ? (accessory_size + accessory_gap) : 0.0f; + const CGFloat default_badge_area_width = + showsDefaultBadge_ ? (default_badge_size + default_badge_gap) : 0.0f; + const CGFloat text_width = + MAX(CGRectGetWidth(card_frame) - leading_inset - horizontal_inset - + accessory_area_width - default_badge_area_width, + 0.0f); + + if (has_thumbnail) { + thumbnailImageView_.frame = + CGRectMake(horizontal_inset, + (CGRectGetHeight(card_frame) - thumbnail_height) * 0.5f, + thumbnail_width, thumbnail_height); + } + + overlayTitleLabel_.frame = + CGRectMake(leading_inset, 8.0f, text_width, 20.0f); + overlaySubtitleLabel_.frame = + CGRectMake(leading_inset, 28.0f, text_width, 16.0f); + + if (showsDefaultBadge_) { + const CGFloat badge_x = + leading_inset + text_width + default_badge_gap * 0.5f; + defaultBadgeView_.frame = + CGRectMake(badge_x, 8.0f + (20.0f - default_badge_size) * 0.5f, + default_badge_size, default_badge_size); + } + + overlayAccessoryView_.hidden = !shows_accessory; + if (showsDisclosure_) { + overlayAccessoryView_.image = [UIImage systemImageNamed:@"chevron.right"]; + overlayAccessoryView_.tintColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacityHeavy]]; + } else if (showsCheckmark_) { + overlayAccessoryView_.image = [UIImage systemImageNamed:@"checkmark"]; + overlayAccessoryView_.tintColor = + [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.95]; + } + overlayAccessoryView_.frame = CGRectMake( + CGRectGetWidth(card_frame) - horizontal_inset - accessory_size, + (CGRectGetHeight(card_frame) - accessory_size) * 0.5f, accessory_size, + accessory_size); +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_layout_store_ios.h b/src/xenia/ui/ios/touch/touch_layout_store_ios.h new file mode 100644 index 000000000..a9cb798e6 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_store_ios.h @@ -0,0 +1,41 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_LAYOUT_STORE_IOS_H_ +#define XENIA_UI_IOS_TOUCH_LAYOUT_STORE_IOS_H_ + +#import + +#include +#include +#include + +#include "xenia/hid/touch/touch_layout_ios.h" +#include "xenia/ui/ios/touch/touch_layout_library_ios.h" + +namespace xe { +namespace ui { + +std::filesystem::path IOSTouchLayoutsDirectory(); +std::filesystem::path IOSTouchLayoutPathForLocalID(const std::string& local_id); +bool EnsureIOSTouchLayoutsDirectory(NSString** error_out = nil); +bool WriteIOSTouchLayoutModel(const xe::hid::touch::IOSTouchLayoutModel& layout, + const std::filesystem::path& layout_path, NSString** error_out = nil); +bool LoadIOSTouchLayoutModelAtPath(const std::filesystem::path& layout_path, + xe::hid::touch::IOSTouchLayoutModel* layout_out, + NSString** error_out = nil); +void EnsureOfficialIOSTouchLayoutPresets(); +std::vector AvailableLocalIOSTouchLayouts(); +std::string UniqueIOSTouchLayoutLocalIDForBaseName( + NSString* base_name, const std::string& existing_local_id = std::string()); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_TOUCH_LAYOUT_STORE_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_store_ios.mm b/src/xenia/ui/ios/touch/touch_layout_store_ios.mm new file mode 100644 index 000000000..5aa01130e --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_store_ios.mm @@ -0,0 +1,316 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/touch/touch_layout_store_ios.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "third_party/fmt/include/fmt/format.h" +#include "xenia/base/filesystem.h" +#include "xenia/base/logging.h" +#include "xenia/config.h" +#import "xenia/ui/ios/shared/ios_system_utils.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" + +namespace xe { +namespace ui { + +namespace { + +std::filesystem::path BackupPathForLayoutPath(const std::filesystem::path& path) { + return path.string() + ".bak"; +} + +bool WriteTextFileAtomically(const std::filesystem::path& path, const std::string& payload, + NSString** error_out) { + std::filesystem::path tmp_path = path.string() + ".tmp"; + std::filesystem::path backup_path = BackupPathForLayoutPath(path); + const std::string tmp_path_utf8 = xe::path_to_utf8(tmp_path); + int fd = ::open(tmp_path_utf8.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { + if (error_out) { + *error_out = [NSString stringWithFormat:@"Could not open temporary layout file: %s", + std::strerror(errno)]; + } + return false; + } + + const char* bytes = payload.data(); + size_t remaining = payload.size(); + while (remaining > 0) { + ssize_t written = ::write(fd, bytes, remaining); + if (written < 0) { + NSString* message = + [NSString stringWithFormat:@"Could not write layout file: %s", std::strerror(errno)]; + ::close(fd); + std::error_code remove_ec; + std::filesystem::remove(tmp_path, remove_ec); + if (error_out) { + *error_out = message; + } + return false; + } + bytes += written; + remaining -= static_cast(written); + } + + if (::fsync(fd) != 0) { + NSString* message = + [NSString stringWithFormat:@"Could not sync layout file: %s", std::strerror(errno)]; + ::close(fd); + std::error_code remove_ec; + std::filesystem::remove(tmp_path, remove_ec); + if (error_out) { + *error_out = message; + } + return false; + } + if (::close(fd) != 0) { + if (error_out) { + *error_out = [NSString stringWithFormat:@"Could not close layout file: %s", + std::strerror(errno)]; + } + std::error_code remove_ec; + std::filesystem::remove(tmp_path, remove_ec); + return false; + } + + std::error_code ec; + if (std::filesystem::exists(path, ec)) { + std::filesystem::copy_file(path, backup_path, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) { + XELOGW("iOS: failed to update touch layout backup {}: {}", + xe::path_to_utf8(backup_path), ec.message()); + ec.clear(); + } + } + std::filesystem::rename(tmp_path, path, ec); + if (ec) { + const std::string rename_error = ec.message(); + std::error_code remove_ec; + std::filesystem::remove(tmp_path, remove_ec); + if (error_out) { + *error_out = + [NSString stringWithFormat:@"Could not replace layout file: %s", + rename_error.c_str()]; + } + return false; + } + return true; +} + +bool LoadIOSTouchLayoutModelAtPathInternal( + const std::filesystem::path& layout_path, + xe::hid::touch::IOSTouchLayoutModel* layout_out, NSString** error_out) { + toml::table table = toml::parse_file(xe::path_to_utf8(layout_path)); + xe::hid::touch::IOSTouchLayoutModel layout = MakeTouchLayoutSeedModelForTable(table); + if (!xe::hid::touch::ApplyIOSTouchLayoutModel(table, &layout)) { + if (error_out) { + *error_out = @"Touch layout file could not be applied."; + } + return false; + } + *layout_out = std::move(layout); + return true; +} + +} // namespace + +std::filesystem::path IOSTouchLayoutsDirectory() { + return xe_get_ios_documents_path() / "touch-layouts"; +} + +std::filesystem::path IOSTouchLayoutPathForLocalID( + const std::string& local_id) { + return IOSTouchLayoutsDirectory() / (local_id + ".toml"); +} + +bool EnsureIOSTouchLayoutsDirectory(NSString** error_out) { + std::error_code ec; + std::filesystem::create_directories(IOSTouchLayoutsDirectory(), ec); + if (ec) { + if (error_out) { + *error_out = + [NSString stringWithFormat:@"Could not create layout folder: %s", + ec.message().c_str()]; + } + XELOGE("iOS: failed to create touch layout directory: {}", ec.message()); + return false; + } + return true; +} + +bool WriteIOSTouchLayoutModel( + const xe::hid::touch::IOSTouchLayoutModel& layout, + const std::filesystem::path& layout_path, NSString** error_out) { + std::error_code ec; + std::filesystem::create_directories(layout_path.parent_path(), ec); + if (ec) { + if (error_out) { + *error_out = + [NSString stringWithFormat:@"Could not create layout folder: %s", + ec.message().c_str()]; + } + return false; + } + + std::ostringstream payload_stream; + payload_stream << "# XeniOS touch layout\n\n"; + payload_stream << xe::hid::touch::EncodeIOSTouchLayoutModel(layout) << "\n"; + if (!WriteTextFileAtomically(layout_path, payload_stream.str(), error_out)) { + XELOGE("iOS: failed to write touch layout {}", + xe::path_to_utf8(layout_path)); + return false; + } + return true; +} + +bool LoadIOSTouchLayoutModelAtPath( + const std::filesystem::path& layout_path, + xe::hid::touch::IOSTouchLayoutModel* layout_out, NSString** error_out) { + if (!layout_out) { + return false; + } + + try { + if (!LoadIOSTouchLayoutModelAtPathInternal(layout_path, layout_out, error_out)) { + return false; + } + return true; + } catch (const std::exception& e) { + std::filesystem::path backup_path = BackupPathForLayoutPath(layout_path); + std::error_code ec; + if (std::filesystem::exists(backup_path, ec)) { + try { + if (LoadIOSTouchLayoutModelAtPathInternal(backup_path, layout_out, error_out)) { + XELOGW("iOS: recovered touch layout {} from backup after parse failure: {}", + xe::path_to_utf8(layout_path), e.what()); + return true; + } + } catch (const std::exception& backup_e) { + XELOGE("iOS: failed to recover touch layout backup {}: {}", + xe::path_to_utf8(backup_path), backup_e.what()); + } + } + if (error_out) { + *error_out = ToNSString(e.what()); + } + XELOGE("iOS: failed to read touch layout {}: {}", + xe::path_to_utf8(layout_path), e.what()); + return false; + } +} + +void EnsureOfficialIOSTouchLayoutPresets() { + if (!EnsureIOSTouchLayoutsDirectory()) { + return; + } + const char* official_layout_ids[] = { + kOfficialTouchLayoutLocalID, + kOfficialTouchLayoutExpandedLocalID, + kOfficialTouchLayoutMirroredLocalID, + }; + for (const char* local_id : official_layout_ids) { + NSString* error_message = nil; + if (!WriteIOSTouchLayoutModel( + MakeOfficialIOSTouchLayoutModelForLocalID(local_id), + IOSTouchLayoutPathForLocalID(local_id), &error_message)) { + XELOGE("iOS: failed to refresh official touch layout {}: {}", local_id, + error_message ? error_message.UTF8String : "unknown error"); + } + } +} + +std::vector AvailableLocalIOSTouchLayouts() { + EnsureOfficialIOSTouchLayoutPresets(); + + std::vector layouts; + std::error_code ec; + const std::filesystem::path layouts_directory = IOSTouchLayoutsDirectory(); + if (!std::filesystem::exists(layouts_directory, ec)) { + return layouts; + } + + for (const auto& entry : + std::filesystem::directory_iterator(layouts_directory, ec)) { + if (ec || !entry.is_regular_file()) { + continue; + } + if (entry.path().extension() != ".toml") { + continue; + } + + const std::string stem = entry.path().stem().string(); + std::string normalized_stem; + if (!TryNormalizeConfiguredTouchLayoutLocalID(stem, &normalized_stem) || + normalized_stem != stem) { + XELOGW("iOS: skipping unsafe touch layout filename {}", stem); + continue; + } + + xe::hid::touch::IOSTouchLayoutModel layout; + NSString* error_message = nil; + if (!LoadIOSTouchLayoutModelAtPath(entry.path(), &layout, &error_message)) { + continue; + } + + IOSTouchLocalLayoutInfo info; + info.path = entry.path(); + info.local_id = normalized_stem; + info.display_name = + layout.display_name.empty() ? info.local_id : layout.display_name; + info.author = layout.author; + info.official = IsOfficialTouchLayoutLocalID(info.local_id); + info.layout = std::move(layout); + layouts.push_back(std::move(info)); + } + + std::sort(layouts.begin(), layouts.end(), + [](const IOSTouchLocalLayoutInfo& left, + const IOSTouchLocalLayoutInfo& right) { + if (left.official != right.official) { + return left.official; + } + if (left.official && right.official) { + return OfficialTouchLayoutPresetSortOrder(left.local_id) < + OfficialTouchLayoutPresetSortOrder(right.local_id); + } + return left.display_name < right.display_name; + }); + return layouts; +} + +std::string UniqueIOSTouchLayoutLocalIDForBaseName( + NSString* base_name, const std::string& existing_local_id) { + std::string base = + MakeTouchLayoutSlug(base_name ? std::string([base_name UTF8String]) + : std::string()); + if (IsOfficialTouchLayoutLocalID(base)) { + base += "_copy"; + } + + std::string local_id = base; + int duplicate_index = 2; + while (local_id != existing_local_id && + std::filesystem::exists(IOSTouchLayoutPathForLocalID(local_id))) { + local_id = fmt::format("{}_{}", base, duplicate_index++); + } + return local_id; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h b/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h new file mode 100644 index 000000000..473744795 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h @@ -0,0 +1,83 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_UI_COORDINATOR_IOS_H_ +#define XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_UI_COORDINATOR_IOS_H_ + +#ifdef __OBJC__ + +#import + +#include +#include +#include + +namespace xe { +namespace hid { +namespace touch { +class IOSTouchRuntimeModel; +} // namespace touch +} // namespace hid +} // namespace xe + +@class XeniaTouchLayoutLibraryItem; + +@protocol XeniaIOSTouchLayoutUICoordinatorHost + +- (xe::hid::touch::IOSTouchRuntimeModel*)touchLayoutCoordinatorRuntimeModel; +- (uint32_t)touchLayoutCoordinatorActiveTitleID; +- (std::string)touchLayoutCoordinatorActiveLocalID; +- (void)touchLayoutCoordinatorSetActiveLocalID:(const std::string&)localID; +- (BOOL)touchLayoutCoordinatorGameRunning; +- (BOOL)touchLayoutCoordinatorCanPresentPendingInstall; +- (UIViewController*)touchLayoutCoordinatorTopPresenter; +- (void)touchLayoutCoordinatorSetGameplayModalPresentationPending:(BOOL)pending; +- (void)touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:(BOOL)animated; +- (void)touchLayoutCoordinatorRefreshTouchOverlayLayoutModel; +- (BOOL)touchLayoutCoordinatorIsShowingLayoutLibrary; +- (void)touchLayoutCoordinatorShowLayoutLibraryWithItems: + (NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID; +- (void)touchLayoutCoordinatorSetStatusText:(NSString*)text; +- (void)touchLayoutCoordinatorPresentAlertWithTitle:(NSString*)title message:(NSString*)message; +- (void)touchLayoutCoordinatorPresentKeyboardPromptWithTitle:(NSString*)title + description:(NSString*)description + defaultText:(NSString*)defaultText + completion:(void (^)(BOOL cancelled, + NSString* text))completion; +- (void)touchLayoutCoordinatorOpenTouchLayoutFileImportPicker; +- (void)touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded; + +@end + +@interface XeniaIOSTouchLayoutUICoordinator : NSObject + +- (instancetype)initWithHost:(id)host; +- (BOOL)hasPendingInstall; +- (void)applyDefaultLayoutModel; +- (void)applyLayoutModelForTitleID:(uint32_t)titleID; +- (void)saveCurrentLayoutForTitleID:(uint32_t)titleID; +- (void)presentLibrary; +- (void)applyLayoutWithLocalID:(NSString*)localID; +- (void)presentRenameSheet; +- (void)presentDeleteSheet; +- (void)saveCurrentLayoutCopy; +- (void)importFromFile; +- (void)importLayoutAtURL:(NSURL*)url; +- (void)exportCurrentLayout; +- (void)resetToOfficialPreset; +- (BOOL)handleExternalFileURL:(NSURL*)url; +- (BOOL)handleExternalSchemeURL:(NSURL*)url; +- (void)presentPendingInstallIfReady; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_TOUCH_TOUCH_LAYOUT_UI_COORDINATOR_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.mm b/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.mm new file mode 100644 index 000000000..568e5eaa1 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.mm @@ -0,0 +1,1067 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_layout_ui_coordinator_ios.h" + +#include +#include +#include +#include +#include +#include + +#include "xenia/base/logging.h" +#include "xenia/config.h" +#include "xenia/hid/touch/touch_layout_ios.h" + +#import "xenia/ui/ios/shared/ios_view_helpers.h" +#import "xenia/ui/ios/touch/touch_layout_library_ios.h" +#import "xenia/ui/ios/touch/touch_layout_library_view_ios.h" +#import "xenia/ui/ios/touch/touch_layout_store_ios.h" + +using xe::ui::IOSTouchLocalLayoutInfo; +using xe::ui::IsOfficialTouchLayoutLocalID; +using xe::ui::MakeOfficialIOSTouchLayoutModel; +using xe::ui::MakeOfficialIOSTouchLayoutModelForLocalID; +using xe::ui::MakeTouchLayoutSeedModelForTable; +using xe::ui::MakeTouchLayoutSlug; +using xe::ui::NormalizeOfficialTouchLayoutBaseTemplate; +using xe::ui::ReadTitleTouchLayoutAssignment; +using xe::ui::RenderTouchLayoutThumbnail; +using xe::ui::TouchLayoutContentMatches; +using xe::ui::TryNormalizeConfiguredTouchLayoutLocalID; +using xe::ui::kOfficialTouchLayoutLocalID; +using xe::ui::kTouchLayoutAssignmentSection; + +namespace { + +constexpr NSUInteger kXeniaIOSTouchLayoutMaxBytes = 64 * 1024; +constexpr NSUInteger kXeniaIOSTouchLayoutURLMaxLength = 2048; + +} // namespace + +@implementation XeniaIOSTouchLayoutUICoordinator { + id _host; + NSData* _pendingInstallBytes; + NSString* _pendingInstallSuggestedName; +} + +- (instancetype)initWithHost:(id)host { + if (!(self = [super init])) { + return nil; + } + _host = host; + return self; +} + +- (void)dealloc { + [_pendingInstallBytes release]; + [_pendingInstallSuggestedName release]; + [super dealloc]; +} + +- (BOOL)hasPendingInstall { + return _pendingInstallBytes != nil; +} + +- (xe::hid::touch::IOSTouchRuntimeModel*)runtimeModel { + return [_host touchLayoutCoordinatorRuntimeModel]; +} + +- (std::filesystem::path)touchLayoutPathForLocalID:(const std::string&)local_id { + return xe::ui::IOSTouchLayoutPathForLocalID(local_id); +} + +- (BOOL)writeTouchLayoutModel:(const xe::hid::touch::IOSTouchLayoutModel&)layout + path:(const std::filesystem::path&)layout_path + error:(NSString**)error_out { + return xe::ui::WriteIOSTouchLayoutModel(layout, layout_path, error_out) ? YES : NO; +} + +- (BOOL)loadTouchLayoutModelAtPath:(const std::filesystem::path&)layout_path + model:(xe::hid::touch::IOSTouchLayoutModel*)layout_out + error:(NSString**)error_out { + return xe::ui::LoadIOSTouchLayoutModelAtPath(layout_path, layout_out, error_out) ? YES : NO; +} + +- (std::vector)availableLocalTouchLayouts { + return xe::ui::AvailableLocalIOSTouchLayouts(); +} + +- (std::string)uniqueTouchLayoutLocalIDForBaseName:(NSString*)base_name { + return xe::ui::UniqueIOSTouchLayoutLocalIDForBaseName(base_name, std::string()); +} + +- (std::string)uniqueTouchLayoutLocalIDForBaseName:(NSString*)base_name + excludingLocalID:(const std::string&)existing_local_id { + return xe::ui::UniqueIOSTouchLayoutLocalIDForBaseName(base_name, existing_local_id); +} + +- (std::string)currentTouchLayoutLibrarySelectionLocalID { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + return runtime_model ? runtime_model->layout().layout_id : std::string(); +} + +- (NSArray*)touchLayoutLibraryItemsForOverlay { + std::vector layouts = [self availableLocalTouchLayouts]; + const uint32_t active_title_id = [_host touchLayoutCoordinatorActiveTitleID]; + const std::string title_default_local_id = ReadTitleTouchLayoutAssignment(active_title_id); + NSMutableArray* items = + [NSMutableArray arrayWithCapacity:layouts.size()]; + for (const IOSTouchLocalLayoutInfo& info : layouts) { + XeniaTouchLayoutLibraryItem* item = [[[XeniaTouchLayoutLibraryItem alloc] init] autorelease]; + item.localID = ToNSString(info.local_id); + item.displayName = ToNSString(info.display_name); + item.author = info.author.empty() ? @"" : ToNSString(info.author); + item.official = info.official; + item.thumbnail = RenderTouchLayoutThumbnail(info.layout, CGSizeMake(120.0, 68.0)); + item.isDefaultForCurrentTitle = + !title_default_local_id.empty() && title_default_local_id == info.local_id; + [items addObject:item]; + } + return items; +} + +- (void)refreshTouchLayoutLibraryOverlayIfVisible { + if (![_host touchLayoutCoordinatorIsShowingLayoutLibrary]) { + return; + } + [_host touchLayoutCoordinatorShowLayoutLibraryWithItems:[self touchLayoutLibraryItemsForOverlay] + currentLayoutLocalID:ToNSString( + [self currentTouchLayoutLibrarySelectionLocalID])]; +} + +- (void)applyDefaultLayoutModel { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!runtime_model) { + return; + } + + [_host touchLayoutCoordinatorSetActiveLocalID:kOfficialTouchLayoutLocalID]; + runtime_model->SetLayout(MakeOfficialIOSTouchLayoutModel()); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; +} + +- (void)applyLayoutModelForTitleID:(uint32_t)title_id { + [self applyDefaultLayoutModel]; + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!title_id || !runtime_model) { + return; + } + + toml::table config_table = config::LoadGameConfig(title_id); + const toml::table* assignment_table = config_table[kTouchLayoutAssignmentSection].as_table(); + if (assignment_table) { + if (auto local_layout_id = (*assignment_table)["local_layout_id"].value()) { + std::string normalized_local_id; + if (!TryNormalizeConfiguredTouchLayoutLocalID(*local_layout_id, &normalized_local_id)) { + XELOGW("iOS: ignoring invalid assigned touch layout id {}", *local_layout_id); + } else if (IsOfficialTouchLayoutLocalID(normalized_local_id)) { + [_host touchLayoutCoordinatorSetActiveLocalID:normalized_local_id]; + runtime_model->SetLayout(MakeOfficialIOSTouchLayoutModelForLocalID(normalized_local_id)); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self saveCurrentLayoutForTitleID:title_id]; + return; + } else { + xe::hid::touch::IOSTouchLayoutModel local_layout; + NSString* error_message = nil; + if ([self loadTouchLayoutModelAtPath:[self touchLayoutPathForLocalID:normalized_local_id] + model:&local_layout + error:&error_message]) { + [_host touchLayoutCoordinatorSetActiveLocalID:normalized_local_id]; + runtime_model->SetLayout(std::move(local_layout)); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self saveCurrentLayoutForTitleID:title_id]; + return; + } + XELOGW("iOS: assigned touch layout {} missing or invalid: {}", normalized_local_id, + error_message ? error_message.UTF8String : "unknown error"); + } + } + } + + const toml::table* touch_layout_table = config_table["TouchLayout"].as_table(); + if (!touch_layout_table) { + return; + } + + xe::hid::touch::IOSTouchLayoutModel layout = MakeTouchLayoutSeedModelForTable(*touch_layout_table); + if (!xe::hid::touch::ApplyIOSTouchLayoutModel(*touch_layout_table, &layout)) { + return; + } + + [_host touchLayoutCoordinatorSetActiveLocalID:std::string()]; + runtime_model->SetLayout(std::move(layout)); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; +} + +- (void)saveCurrentLayoutForTitleID:(uint32_t)title_id { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!title_id || !runtime_model) { + return; + } + + toml::table config_table = config::LoadGameConfig(title_id); + const xe::hid::touch::IOSTouchLayoutModel& layout = runtime_model->layout(); + + std::string assignment_local_id; + const std::string active_local_id = [_host touchLayoutCoordinatorActiveLocalID]; + if (!active_local_id.empty()) { + xe::hid::touch::IOSTouchLayoutModel source_layout; + bool source_loaded = false; + if (IsOfficialTouchLayoutLocalID(active_local_id)) { + source_layout = MakeOfficialIOSTouchLayoutModelForLocalID(active_local_id); + source_loaded = true; + } else { + const std::filesystem::path source_path = [self touchLayoutPathForLocalID:active_local_id]; + NSString* source_error = nil; + source_loaded = + [self loadTouchLayoutModelAtPath:source_path model:&source_layout error:&source_error]; + } + if (source_loaded && TouchLayoutContentMatches(layout, source_layout)) { + assignment_local_id = active_local_id; + } + } + + if (!assignment_local_id.empty()) { + toml::table assignment_table; + assignment_table.insert_or_assign("local_layout_id", assignment_local_id); + config_table.insert_or_assign(kTouchLayoutAssignmentSection, std::move(assignment_table)); + config_table.erase("TouchLayout"); + } else { + config_table.insert_or_assign("TouchLayout", xe::hid::touch::EncodeIOSTouchLayoutModel(layout)); + config_table.erase(kTouchLayoutAssignmentSection); + } + + try { + config::SaveGameConfig(title_id, config_table); + } catch (const std::exception& e) { + XELOGE("iOS: failed to save touch layout for title {:08X}: {}", title_id, e.what()); + } +} + +- (void)presentLibrary { + if (![self runtimeModel]) { + return; + } + [_host touchLayoutCoordinatorShowLayoutLibraryWithItems:[self touchLayoutLibraryItemsForOverlay] + currentLayoutLocalID:ToNSString( + [self currentTouchLayoutLibrarySelectionLocalID])]; +} + +- (void)applyLayoutWithLocalID:(NSString*)localID { + if (!localID.length) { + return; + } + std::string selected_local_id([localID UTF8String]); + std::vector layouts = [self availableLocalTouchLayouts]; + for (const IOSTouchLocalLayoutInfo& info : layouts) { + if (info.local_id == selected_local_id) { + [self applyTouchLayoutInfo:info]; + return; + } + } + [_host touchLayoutCoordinatorSetStatusText:@"Selected layout is no longer available."]; +} + +- (void)applyTouchLayoutInfo:(const IOSTouchLocalLayoutInfo&)info { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!runtime_model) { + return; + } + + xe::hid::touch::IOSTouchLayoutModel layout; + NSString* error_message = nil; + if (![self loadTouchLayoutModelAtPath:info.path model:&layout error:&error_message]) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Layout could not be loaded."]; + return; + } + + [_host touchLayoutCoordinatorSetActiveLocalID:info.local_id]; + runtime_model->SetLayout(std::move(layout)); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [self saveCurrentLayoutForTitleID:[_host touchLayoutCoordinatorActiveTitleID]]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Applied %@.", ToNSString(info.display_name)]]; +} + +- (void)presentRenameSheet { + std::vector local_layouts = [self availableLocalTouchLayouts]; + local_layouts.erase( + std::remove_if(local_layouts.begin(), local_layouts.end(), + [](const IOSTouchLocalLayoutInfo& info) { return info.official; }), + local_layouts.end()); + if (local_layouts.empty()) { + [_host touchLayoutCoordinatorSetStatusText:@"No saved local layouts to rename."]; + return; + } + + UIAlertController* sheet = + [UIAlertController alertControllerWithTitle:@"Rename Saved Layout" + message:@"Choose a local layout to rename." + preferredStyle:UIAlertControllerStyleActionSheet]; + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + for (const IOSTouchLocalLayoutInfo& info : local_layouts) { + IOSTouchLocalLayoutInfo selected_info = info; + NSString* current_name = ToNSString(selected_info.display_name); + [sheet + addAction: + [UIAlertAction + actionWithTitle:current_name + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* action) { + [unsafe_self renameLayout:selected_info currentName:current_name]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + + [self presentActionSheet:sheet]; +} + +- (void)renameLayout:(const IOSTouchLocalLayoutInfo&)selected_info + currentName:(NSString*)current_name { + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + [_host touchLayoutCoordinatorPresentKeyboardPromptWithTitle:@"Rename Layout" + description:@"Enter a new name for the saved " + @"local layout." + defaultText:current_name + completion:^(BOOL cancelled, NSString* text) { + [unsafe_self finishRenameLayout:selected_info + text:text + cancelled:cancelled]; + }]; +} + +- (void)finishRenameLayout:(const IOSTouchLocalLayoutInfo&)selected_info + text:(NSString*)text + cancelled:(BOOL)cancelled { + if (cancelled) { + return; + } + NSString* trimmed = + [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0) { + return; + } + + xe::hid::touch::IOSTouchLayoutModel layout; + NSString* error_message = nil; + if (![self loadTouchLayoutModelAtPath:selected_info.path model:&layout error:&error_message]) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Layout could not be loaded."]; + return; + } + + std::string next_local_id = [self uniqueTouchLayoutLocalIDForBaseName:trimmed + excludingLocalID:selected_info.local_id]; + const std::filesystem::path next_path = [self touchLayoutPathForLocalID:next_local_id]; + layout.layout_id = next_local_id; + layout.display_name = std::string([trimmed UTF8String]); + if (layout.author.empty()) { + layout.author = "Local"; + } + + if (![self writeTouchLayoutModel:layout path:next_path error:&error_message]) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Failed to rename saved layout."]; + return; + } + + if (next_local_id != selected_info.local_id) { + std::error_code remove_ec; + std::filesystem::remove(selected_info.path, remove_ec); + if (remove_ec) { + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Renamed %@, but the old file could not be removed.", + trimmed]]; + return; + } + } + + if ([_host touchLayoutCoordinatorActiveLocalID] == selected_info.local_id) { + [_host touchLayoutCoordinatorSetActiveLocalID:std::string()]; + } + + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [_host touchLayoutCoordinatorSetStatusText:[NSString stringWithFormat:@"Renamed %@.", trimmed]]; +} + +- (void)presentDeleteSheet { + std::vector local_layouts = [self availableLocalTouchLayouts]; + local_layouts.erase( + std::remove_if(local_layouts.begin(), local_layouts.end(), + [](const IOSTouchLocalLayoutInfo& info) { return info.official; }), + local_layouts.end()); + if (local_layouts.empty()) { + [_host touchLayoutCoordinatorSetStatusText:@"No saved local layouts to delete."]; + return; + } + + UIAlertController* sheet = + [UIAlertController alertControllerWithTitle:@"Delete Saved Layout" + message:@"Choose a local layout to delete." + preferredStyle:UIAlertControllerStyleActionSheet]; + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + for (const IOSTouchLocalLayoutInfo& info : local_layouts) { + IOSTouchLocalLayoutInfo selected_info = info; + NSString* current_name = ToNSString(selected_info.display_name); + [sheet + addAction: + [UIAlertAction + actionWithTitle:current_name + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction* action) { + [unsafe_self confirmDeleteLayout:selected_info currentName:current_name]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + + [self presentActionSheet:sheet]; +} + +- (void)confirmDeleteLayout:(const IOSTouchLocalLayoutInfo&)selected_info + currentName:(NSString*)current_name { + UIAlertController* confirm = + [UIAlertController alertControllerWithTitle:@"Delete Layout?" + message:@"This removes the saved local layout file." + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + [confirm + addAction:[UIAlertAction actionWithTitle:@"Delete" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction* delete_action) { + [unsafe_self deleteLayout:selected_info + currentName:current_name]; + }]]; + [[_host touchLayoutCoordinatorTopPresenter] presentViewController:confirm + animated:YES + completion:nil]; +} + +- (void)deleteLayout:(const IOSTouchLocalLayoutInfo&)selected_info + currentName:(NSString*)current_name { + std::error_code remove_ec; + const bool removed = std::filesystem::remove(selected_info.path, remove_ec); + if (remove_ec || !removed) { + [_host touchLayoutCoordinatorSetStatusText:@"Failed to delete saved layout."]; + return; + } + + if ([_host touchLayoutCoordinatorActiveLocalID] == selected_info.local_id) { + [_host touchLayoutCoordinatorSetActiveLocalID:std::string()]; + } + + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Deleted %@.", current_name]]; +} + +- (void)saveCurrentLayoutCopy { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!runtime_model) { + return; + } + + NSString* suggested_name = runtime_model->layout().display_name.empty() + ? @"Custom Layout" + : ToNSString(runtime_model->layout().display_name); + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + [_host touchLayoutCoordinatorPresentKeyboardPromptWithTitle:@"Save Layout Copy" + description:@"Enter a name for the saved local " + @"layout." + defaultText:suggested_name + completion:^(BOOL cancelled, NSString* text) { + [unsafe_self finishSaveLayoutCopy:text + cancelled:cancelled]; + }]; +} + +- (void)finishSaveLayoutCopy:(NSString*)text cancelled:(BOOL)cancelled { + if (cancelled) { + return; + } + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!runtime_model) { + return; + } + + NSString* trimmed = + [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0) { + return; + } + + std::string local_id = [self uniqueTouchLayoutLocalIDForBaseName:trimmed]; + xe::hid::touch::IOSTouchLayoutModel layout = runtime_model->layout(); + layout.layout_id = local_id; + layout.display_name = std::string([trimmed UTF8String]); + layout.author = "Local"; + layout.base_template = NormalizeOfficialTouchLayoutBaseTemplate(layout.base_template); + + NSString* error_message = nil; + if (![self writeTouchLayoutModel:layout + path:[self touchLayoutPathForLocalID:local_id] + error:&error_message]) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Failed to save layout copy."]; + return; + } + + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Saved copy %@.", trimmed]]; +} + +- (void)importFromFile { + if ([_host touchLayoutCoordinatorGameRunning]) { + [_host touchLayoutCoordinatorSetGameplayModalPresentationPending:YES]; + [_host touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:YES]; + } + [_host touchLayoutCoordinatorOpenTouchLayoutFileImportPicker]; +} + +- (void)importLayoutAtURL:(NSURL*)url { + BOOL touch_access_granted = [url startAccessingSecurityScopedResource]; + NSString* error_message = nil; + xe::hid::touch::IOSTouchLayoutModel imported_layout; + BOOL ok = [self loadTouchLayoutModelAtPath:std::filesystem::path([url.path UTF8String]) + model:&imported_layout + error:&error_message]; + if (touch_access_granted) { + [url stopAccessingSecurityScopedResource]; + } + if (!ok) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Failed to import touch layout."]; + } else if ([self runtimeModel]) { + std::string base_name = imported_layout.display_name; + if (base_name.empty()) { + NSString* fallback_name = [[url lastPathComponent] stringByDeletingPathExtension]; + base_name = + fallback_name.length ? std::string([fallback_name UTF8String]) : "imported_layout"; + } + std::string local_id = [self uniqueTouchLayoutLocalIDForBaseName:ToNSString(base_name)]; + imported_layout.layout_id = local_id; + if (imported_layout.author.empty()) { + imported_layout.author = "Imported"; + } + imported_layout.base_template = + NormalizeOfficialTouchLayoutBaseTemplate(imported_layout.base_template); + NSString* write_error = nil; + if ([self writeTouchLayoutModel:imported_layout + path:[self touchLayoutPathForLocalID:local_id] + error:&write_error]) { + [_host touchLayoutCoordinatorSetActiveLocalID:std::string()]; + [self runtimeModel]->SetLayout(imported_layout); + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [self saveCurrentLayoutForTitleID:[_host touchLayoutCoordinatorActiveTitleID]]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Imported %@.", + ToNSString(imported_layout.display_name)]]; + } else { + [_host touchLayoutCoordinatorSetStatusText:write_error ?: @"Failed to save imported touch layout."]; + } + } + [_host touchLayoutCoordinatorSetGameplayModalPresentationPending:NO]; + [_host touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:YES]; +} + +- (void)exportCurrentLayout { + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (!runtime_model) { + return; + } + + std::string base_name = runtime_model->layout().display_name.empty() + ? "touch_layout" + : MakeTouchLayoutSlug(runtime_model->layout().display_name); + std::filesystem::path export_path = std::filesystem::path([NSTemporaryDirectory() UTF8String]) / + (base_name + ".touchlayout.toml"); + NSString* error_message = nil; + if (![self writeTouchLayoutModel:runtime_model->layout() path:export_path error:&error_message]) { + [_host touchLayoutCoordinatorSetStatusText:error_message ?: @"Failed to export touch layout."]; + return; + } + + if ([_host touchLayoutCoordinatorGameRunning]) { + [_host touchLayoutCoordinatorSetGameplayModalPresentationPending:YES]; + [_host touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:YES]; + } + + UIActivityViewController* activity_controller = [[UIActivityViewController alloc] + initWithActivityItems:@[ [NSURL fileURLWithPath:ToNSString(export_path.string())] ] + applicationActivities:nil]; + __unsafe_unretained id host = _host; + activity_controller.completionWithItemsHandler = + ^(__unused UIActivityType activity_type, __unused BOOL completed, + __unused NSArray* returned_items, __unused NSError* activity_error) { + [host touchLayoutCoordinatorSetGameplayModalPresentationPending:NO]; + [host touchLayoutCoordinatorUpdateTouchOverlayVisibilityAnimated:YES]; + }; + [self presentActionSheet:activity_controller]; + [activity_controller release]; +} + +- (void)resetToOfficialPreset { + xe::ui::EnsureOfficialIOSTouchLayoutPresets(); + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + const std::string base_template = NormalizeOfficialTouchLayoutBaseTemplate( + runtime_model ? runtime_model->layout().base_template : std::string()); + const xe::hid::touch::IOSTouchLayoutModel preset_layout = + MakeOfficialIOSTouchLayoutModelForLocalID(base_template); + [_host touchLayoutCoordinatorSetActiveLocalID:base_template]; + if (runtime_model) { + runtime_model->SetLayout(preset_layout); + } + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [self saveCurrentLayoutForTitleID:[_host touchLayoutCoordinatorActiveTitleID]]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Restored %@.", ToNSString(preset_layout.display_name)]]; +} + +- (BOOL)handleExternalFileURL:(NSURL*)url { + if (!url || ![url isFileURL]) { + return NO; + } + NSString* last_component = url.lastPathComponent ?: @""; + NSString* lower_name = last_component.lowercaseString; + static NSArray* const game_extensions = + @[ @".iso", @".xex", @".zar", @".god", @".xbe", @".xbox", @".xcp", @".001" ]; + for (NSString* ext in game_extensions) { + if ([lower_name hasSuffix:ext]) { + return NO; + } + } + + BOOL access_granted = [url startAccessingSecurityScopedResource]; + NSError* read_error = nil; + NSData* file_bytes = nil; + NSNumber* file_size = nil; + if ([url getResourceValue:&file_size forKey:NSURLFileSizeKey error:nil] && file_size && + file_size.unsignedLongLongValue > kXeniaIOSTouchLayoutMaxBytes) { + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + return NO; + } + file_bytes = [NSData dataWithContentsOfURL:url options:0 error:&read_error]; + if (access_granted) { + [url stopAccessingSecurityScopedResource]; + } + if (!file_bytes || file_bytes.length == 0 || file_bytes.length > kXeniaIOSTouchLayoutMaxBytes) { + return NO; + } + + std::string_view bytes_view(static_cast(file_bytes.bytes), file_bytes.length); + xe::hid::touch::IOSTouchLayoutModel sniff_layout; + try { + toml::table table = toml::parse(bytes_view); + sniff_layout = MakeTouchLayoutSeedModelForTable(table); + if (!xe::hid::touch::ApplyIOSTouchLayoutModel(table, &sniff_layout)) { + return NO; + } + } catch (const std::exception&) { + return NO; + } + + NSString* suggested_name = [last_component stringByDeletingPathExtension]; + if ([suggested_name.lowercaseString hasSuffix:@".touchlayout"]) { + suggested_name = [suggested_name stringByDeletingPathExtension]; + } + XELOGD("iOS: Touch layout sniff matched; stashing install for {}", + suggested_name.length ? suggested_name.UTF8String : ""); + + [_pendingInstallBytes release]; + _pendingInstallBytes = [file_bytes retain]; + [_pendingInstallSuggestedName release]; + _pendingInstallSuggestedName = [suggested_name retain]; + [self presentPendingInstallIfReady]; + return YES; +} + +- (void)presentPendingInstallIfReady { + if (!_pendingInstallBytes || ![_host touchLayoutCoordinatorCanPresentPendingInstall]) { + return; + } + + NSData* bytes = [[_pendingInstallBytes retain] autorelease]; + NSString* suggested = [[_pendingInstallSuggestedName retain] autorelease]; + [_pendingInstallBytes release]; + _pendingInstallBytes = nil; + [_pendingInstallSuggestedName release]; + _pendingInstallSuggestedName = nil; + + NSString* display_for_prompt = suggested.length ? suggested : @"this layout"; + NSString* confirm_message = + [NSString stringWithFormat:@"Install \"%@\" into your XeniOS touch layout library?", + display_for_prompt]; + XELOGD("iOS: Presenting touch layout install confirm for {}", display_for_prompt.UTF8String); + NSString* error_message = nil; + if (![self installTouchLayoutFromTOMLBytes:bytes + suggestedName:suggested + confirmTitle:@"Install Touch Layout" + confirmMessage:confirm_message + confirmLabel:@"Install" + error:&error_message]) { + if (error_message.length) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" message:error_message]; + } + } +} + +- (BOOL)handleExternalSchemeURL:(NSURL*)url { + if (!url) { + return NO; + } + NSString* scheme = url.scheme.lowercaseString; + NSString* host = url.host.lowercaseString; + if (![scheme isEqualToString:@"xenios"] || ![host isEqualToString:@"touchlayout"]) { + return NO; + } + NSString* absolute = url.absoluteString ?: @""; + if (absolute.length > kXeniaIOSTouchLayoutURLMaxLength) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"This install link is too long to be safe " + @"and was rejected."]; + return YES; + } + + NSURLComponents* components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + NSString* source_param = nil; + NSString* file_param = nil; + for (NSURLQueryItem* item in components.queryItems) { + if ([item.name isEqualToString:@"source"]) { + source_param = item.value; + } else if ([item.name isEqualToString:@"file"]) { + file_param = item.value; + } + } + + if (file_param.length) { + NSData* decoded = [[[NSData alloc] + initWithBase64EncodedString:file_param + options:NSDataBase64DecodingIgnoreUnknownCharacters] autorelease]; + if (!decoded || decoded.length == 0) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"This install link's payload could not be " + @"decoded."]; + return YES; + } + if (decoded.length > kXeniaIOSTouchLayoutMaxBytes) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"The embedded layout is larger than 64 KB " + @"and was rejected."]; + return YES; + } + NSString* error_message = nil; + if (![self installTouchLayoutFromTOMLBytes:decoded + suggestedName:@"shared_layout" + confirmTitle:@"Install Touch Layout" + confirmMessage:@"Install the touch layout from this link?" + confirmLabel:@"Install" + error:&error_message]) { + if (error_message.length) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" message:error_message]; + } + } + return YES; + } + + if (source_param.length) { + NSURL* source_url = [NSURL URLWithString:source_param]; + if (!source_url || ![source_url.scheme.lowercaseString isEqualToString:@"https"] || + source_url.host.length == 0) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"Layout downloads must use a valid " + @"https:// URL."]; + return YES; + } + if (source_param.length > kXeniaIOSTouchLayoutURLMaxLength) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"The download URL is too long to be safe " + @"and was rejected."]; + return YES; + } + + NSString* host_label = source_url.host ?: @"the source"; + NSString* prompt_message = + [NSString stringWithFormat:@"Download a touch layout from %@?", host_label]; + UIAlertController* confirm = + [UIAlertController alertControllerWithTitle:@"Touch Layout" + message:prompt_message + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + [confirm addAction:[UIAlertAction actionWithTitle:@"Download" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* _action) { + [unsafe_self downloadTouchLayoutFromURL:source_url + hostLabel:host_label]; + }]]; + [[_host touchLayoutCoordinatorTopPresenter] presentViewController:confirm + animated:YES + completion:nil]; + return YES; + } + + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" + message:@"This link is missing a 'source' URL or " + @"'file' payload."]; + return YES; +} + +- (void)downloadTouchLayoutFromURL:(NSURL*)source_url hostLabel:(NSString*)host_label { + if (!source_url) { + return; + } + + NSString* display_host = host_label.length ? host_label : (source_url.host ?: @"the source"); + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Downloading touch layout from %@...", display_host]]; + + NSMutableURLRequest* request = + [NSMutableURLRequest requestWithURL:source_url + cachePolicy:NSURLRequestReloadIgnoringLocalCacheData + timeoutInterval:20.0]; + [request setValue:@"text/plain, application/toml, */*;q=0.1" forHTTPHeaderField:@"Accept"]; + + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + NSURLSessionDownloadTask* task = [[NSURLSession sharedSession] + downloadTaskWithRequest:request + completionHandler:^(NSURL* location, NSURLResponse* response, NSError* download_error) { + [unsafe_self finishDownloadedTouchLayoutFromURL:source_url + hostLabel:display_host + location:location + response:response + error:download_error]; + }]; + [task resume]; +} + +- (void)finishDownloadedTouchLayoutFromURL:(NSURL*)source_url + hostLabel:(NSString*)display_host + location:(NSURL*)location + response:(NSURLResponse*)response + error:(NSError*)download_error { + NSString* failure = nil; + NSData* response_data = nil; + + if (download_error || !location) { + failure = download_error.localizedDescription ?: @"The touch layout could not be downloaded."; + } + + if (!failure && [response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse* http_response = (NSHTTPURLResponse*)response; + if (http_response.statusCode < 200 || http_response.statusCode >= 300) { + failure = + [NSString stringWithFormat:@"Download failed with HTTP %ld.", + static_cast(http_response.statusCode)]; + } + } + + if (!failure && response.expectedContentLength > 0 && + static_cast(response.expectedContentLength) > + kXeniaIOSTouchLayoutMaxBytes) { + failure = @"The downloaded layout is larger than 64 KB and was rejected."; + } + + if (!failure) { + NSError* file_error = nil; + NSDictionary* attributes = + [[NSFileManager defaultManager] attributesOfItemAtPath:location.path error:&file_error]; + NSNumber* file_size = [attributes objectForKey:NSFileSize]; + if (!file_size) { + failure = file_error.localizedDescription ?: @"The downloaded layout could not be inspected."; + } else if (file_size.unsignedLongLongValue == 0) { + failure = @"The downloaded layout was empty."; + } else if (file_size.unsignedLongLongValue > kXeniaIOSTouchLayoutMaxBytes) { + failure = @"The downloaded layout is larger than 64 KB and was rejected."; + } + } + + if (!failure) { + NSError* read_error = nil; + response_data = [NSData dataWithContentsOfURL:location options:0 error:&read_error]; + if (!response_data || response_data.length == 0) { + failure = read_error.localizedDescription ?: @"The downloaded layout could not be read."; + } else if (response_data.length > kXeniaIOSTouchLayoutMaxBytes) { + failure = @"The downloaded layout is larger than 64 KB and was rejected."; + response_data = nil; + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure.length || !response_data) { + NSString* message = failure.length ? failure : @"The touch layout could not be downloaded."; + [_host touchLayoutCoordinatorSetStatusText:message]; + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" message:message]; + return; + } + + NSString* suggested = source_url.lastPathComponent.stringByDeletingPathExtension; + if ([suggested.lowercaseString hasSuffix:@".touchlayout"]) { + suggested = [suggested stringByDeletingPathExtension]; + } + if (!suggested.length) { + suggested = @"downloaded_layout"; + } + + NSString* install_message = + [NSString stringWithFormat:@"Install the touch layout from %@?", display_host]; + NSString* error_message = nil; + if (![self installTouchLayoutFromTOMLBytes:response_data + suggestedName:suggested + confirmTitle:@"Install Touch Layout" + confirmMessage:install_message + confirmLabel:@"Install" + error:&error_message]) { + if (error_message.length) { + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" message:error_message]; + } + } + }); +} + +- (BOOL)installTouchLayoutFromTOMLBytes:(NSData*)bytes + suggestedName:(NSString*)suggestedName + confirmTitle:(NSString*)confirmTitle + confirmMessage:(NSString*)confirmMessage + confirmLabel:(NSString*)confirmLabel + error:(NSString**)error_out { + if (!bytes || bytes.length == 0) { + if (error_out) { + *error_out = @"The touch layout payload was empty."; + } + return NO; + } + + std::string_view bytes_view(static_cast(bytes.bytes), bytes.length); + xe::hid::touch::IOSTouchLayoutModel parsed_layout; + try { + toml::table table = toml::parse(bytes_view); + parsed_layout = MakeTouchLayoutSeedModelForTable(table); + if (!xe::hid::touch::ApplyIOSTouchLayoutModel(table, &parsed_layout)) { + if (error_out) { + *error_out = @"Touch layout file could not be applied."; + } + return NO; + } + } catch (const std::exception& parse_error) { + if (error_out) { + *error_out = + [NSString stringWithFormat:@"Failed to parse touch layout: %s", parse_error.what()]; + } + return NO; + } + + NSString* effective_display = nil; + if (!parsed_layout.display_name.empty()) { + effective_display = ToNSString(parsed_layout.display_name); + } else if (suggestedName.length) { + effective_display = suggestedName; + } else { + effective_display = @"Imported Layout"; + } + NSString* sheet_message = confirmMessage.length + ? confirmMessage + : [NSString stringWithFormat:@"Install \"%@\"?", effective_display]; + + xe::hid::touch::IOSTouchLayoutModel* layout_copy = + new xe::hid::touch::IOSTouchLayoutModel(parsed_layout); + __unsafe_unretained XeniaIOSTouchLayoutUICoordinator* unsafe_self = self; + NSString* base_name_for_id = effective_display; + + UIAlertController* alert = + [UIAlertController alertControllerWithTitle:confirmTitle ?: @"Install Touch Layout" + message:sheet_message + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction + actionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:^(__unused UIAlertAction* _action) { + delete layout_copy; + [unsafe_self->_host + touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded]; + }]]; + [alert + addAction:[UIAlertAction + actionWithTitle:confirmLabel.length ? confirmLabel : @"Install" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction* _action) { + [unsafe_self installParsedLayout:layout_copy + baseNameForID:base_name_for_id]; + }]]; + + UIAlertController* alert_to_present = [alert retain]; + __unsafe_unretained id host = _host; + dispatch_async(dispatch_get_main_queue(), ^{ + [[host touchLayoutCoordinatorTopPresenter] presentViewController:alert_to_present + animated:YES + completion:nil]; + [alert_to_present release]; + }); + return YES; +} + +- (void)installParsedLayout:(xe::hid::touch::IOSTouchLayoutModel*)layout_copy + baseNameForID:(NSString*)base_name_for_id { + std::string local_id = [self uniqueTouchLayoutLocalIDForBaseName:base_name_for_id]; + layout_copy->layout_id = local_id; + if (layout_copy->author.empty()) { + layout_copy->author = "Imported"; + } + layout_copy->base_template = NormalizeOfficialTouchLayoutBaseTemplate(layout_copy->base_template); + NSString* write_error = nil; + BOOL ok = [self writeTouchLayoutModel:*layout_copy + path:[self touchLayoutPathForLocalID:local_id] + error:&write_error]; + if (!ok) { + NSString* message = + write_error.length ? write_error : @"The touch layout could not be saved."; + [_host touchLayoutCoordinatorSetStatusText:message]; + [_host touchLayoutCoordinatorPresentAlertWithTitle:@"Touch Layout" message:message]; + delete layout_copy; + [_host touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded]; + return; + } + [_host touchLayoutCoordinatorSetActiveLocalID:std::string()]; + xe::hid::touch::IOSTouchRuntimeModel* runtime_model = [self runtimeModel]; + if (runtime_model) { + runtime_model->SetLayout(*layout_copy); + } + [_host touchLayoutCoordinatorRefreshTouchOverlayLayoutModel]; + [self refreshTouchLayoutLibraryOverlayIfVisible]; + [self saveCurrentLayoutForTitleID:[_host touchLayoutCoordinatorActiveTitleID]]; + [_host touchLayoutCoordinatorSetStatusText: + [NSString stringWithFormat:@"Imported %@.", ToNSString(layout_copy->display_name)]]; + delete layout_copy; + [_host touchLayoutCoordinatorEvaluateAutomaticStikDebugJITHandoffIfNeeded]; +} + +- (void)presentActionSheet:(UIViewController*)controller { + UIViewController* presenter = [_host touchLayoutCoordinatorTopPresenter]; + UIPopoverPresentationController* popover = controller.popoverPresentationController; + if (popover) { + popover.sourceView = presenter.view; + popover.sourceRect = CGRectMake(CGRectGetMidX(popover.sourceView.bounds), + CGRectGetMidY(popover.sourceView.bounds), 1.0, 1.0); + popover.permittedArrowDirections = 0; + } + [presenter presentViewController:controller animated:YES completion:nil]; +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h b/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h new file mode 100644 index 000000000..44a5902da --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h @@ -0,0 +1,117 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_CHROME_IOS_H_ +#define XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_CHROME_IOS_H_ + +#ifdef __OBJC__ + +#import + +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +@class XeniaTouchLayoutLibraryItem; +@class XeniaTouchOverlayEditChromeIOS; + +namespace xe::ui::ios::touch_overlay { + +struct TouchOverlayEditChromeState { + bool editing_enabled = false; + bool showing_layout_library = false; + bool minimized = false; + bool grid_enabled = false; + bool can_undo = false; + bool can_redo = false; + bool editing_portrait = false; + bool has_selected_control = false; + bool can_match_selected_size = false; + bool layout_contains_move_stick = false; + bool layout_contains_look_zone = false; + bool layout_contains_pause_button = false; + size_t control_count = 0; + xe::hid::touch::IOSTouchControlDefinition selected_control; +}; + +} // namespace xe::ui::ios::touch_overlay + +@protocol XeniaTouchOverlayEditChromeIOSDelegate + +- (void)touchOverlayEditChromeDidRequestDoneEditing:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestSmallerControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestMatchNearestSize:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLargerControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestDimmerControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestBolderControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestCycleAction:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestRenameLabel:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestCycleTint:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestCycleShape:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestDuplicateControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestUndo:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestRedo:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestCollapse:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestToggleGrid:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestAddDefaultControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestDeleteControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibrary:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestHideLayoutLibrary:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibrarySaveCopy: + (XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibraryRename:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibraryDelete:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibraryImport:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibraryExport:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestLayoutLibraryReset:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestLayoutLibraryLoad:(NSString*)localID; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestAction:(xe::hid::touch::IOSTouchAction)action; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestLabelHidden:(BOOL)hidden; +- (void)touchOverlayEditChromeDidRequestResetLabel:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestBehaviorTrigger:(xe::hid::touch::IOSTouchInteractionTrigger)trigger; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestBehaviorAction:(xe::hid::touch::IOSTouchAction)action; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestTintStyle:(xe::hid::touch::IOSTouchTintStyle)tintStyle; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestLookScale:(float)lookScale; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestShape:(xe::hid::touch::IOSTouchControlShape)shape; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestCopyLayoutFromLandscape:(BOOL)fromLandscape; +- (void)touchOverlayEditChromeDidRequestMirrorControl:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChromeDidRequestToggleMoveDpadRing:(XeniaTouchOverlayEditChromeIOS*)chrome; +- (void)touchOverlayEditChrome:(XeniaTouchOverlayEditChromeIOS*)chrome + didRequestAddControlOfType:(xe::hid::touch::IOSTouchControlType)type; + +@end + +@interface XeniaTouchOverlayEditChromeIOS : UIView + +@property(nonatomic, assign) id delegate; + +- (void)applyState:(const xe::ui::ios::touch_overlay::TouchOverlayEditChromeState&)state; +- (CGFloat)preferredHeightForWidth:(CGFloat)width + availableHeight:(CGFloat)availableHeight + margin:(CGFloat)margin; +- (UIView*)interactiveHitTestForOverlayPoint:(CGPoint)point + event:(UIEvent*)event + inView:(UIView*)overlayView; +- (void)setLayoutLibraryItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID; + +@end + +#endif // __OBJC__ + +#endif // XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_CHROME_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.mm b/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.mm new file mode 100644 index 000000000..b4a7592de --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.mm @@ -0,0 +1,1185 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#import "xenia/ui/ios/touch/touch_overlay_edit_chrome_ios.h" + +#include +#include +#include +#include + +#import "xenia/ui/ios/shared/ios_theme_controls.h" +#import "xenia/ui/ios/shared/ios_view_helpers.h" +#import "xenia/ui/ios/touch/touch_controls_overlay_helpers_ios.h" +#import "xenia/ui/ios/touch/touch_layout_library_controller_ios.h" +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +namespace { + +using xe::ui::XeniaTouchConfiguredControlLabelText; +using namespace xe::ui::ios::touch_overlay; + +constexpr CGFloat kChromeInset = 14.0f; +constexpr CGFloat kHeaderTop = 10.0f; +constexpr CGFloat kHeaderButtonHeight = 28.0f; +constexpr CGFloat kHeaderGap = 8.0f; +constexpr CGFloat kDoneWidth = 56.0f; +constexpr CGFloat kLayoutsWidth = 64.0f; +constexpr CGFloat kGridWidth = 48.0f; +constexpr CGFloat kCollapseWidth = 48.0f; +constexpr CGFloat kRowGap = 6.0f; +constexpr CGFloat kButtonHeight = 32.0f; + +UIButton* CreateChromeButton(NSString* title, id target, SEL selector) { + UIButton* button = [[UIButton buttonWithType:UIButtonTypeSystem] retain]; + button.hidden = YES; + // The touch overlay editor renders over the game view (which is full-bleed + // dark game content), so white-on-dark with token opacities reads more + // legibly than the theme's bgSurface tints. The high-alpha title (0.94) + // stays a literal — it's the "almost solid text" band that the current + // token scale doesn't model and isn't worth a one-off token. + button.backgroundColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]]; + button.layer.cornerRadius = XeniaRadiusLg; + xe_apply_button_title_font(button, UIFontTextStyleFootnote, 13.0, + UIFontWeightSemibold); + button.titleLabel.adjustsFontSizeToFitWidth = YES; + button.titleLabel.minimumScaleFactor = 0.60f; + [button setTitleColor:[[UIColor whiteColor] colorWithAlphaComponent:0.94] + forState:UIControlStateNormal]; + [button setTitle:title forState:UIControlStateNormal]; + button.accessibilityLabel = title; + button.accessibilityTraits = UIAccessibilityTraitButton; + [button addTarget:target action:selector forControlEvents:UIControlEventTouchUpInside]; + return button; +} + +void ConfigureMenuButton(UIButton* button, UIMenu* menu) { + button.menu = menu; + button.showsMenuAsPrimaryAction = menu != nil; +} + +} // namespace + +@implementation XeniaTouchOverlayEditChromeIOS { + id delegate_; + UILabel* title_label_; + UILabel* selection_label_; + UILabel* preview_label_; + UIButton* smaller_button_; + UIButton* match_size_button_; + UIButton* larger_button_; + UIButton* dimmer_button_; + UIButton* bolder_button_; + UIButton* action_button_; + UIButton* label_button_; + UIButton* behavior_button_; + UIButton* color_button_; + UIButton* shape_button_; + UIButton* duplicate_button_; + UIButton* undo_button_; + UIButton* redo_button_; + UIButton* collapse_button_; + UIButton* grid_button_; + UIButton* add_button_; + UIButton* delete_button_; + UIButton* library_button_; + UIButton* done_button_; + UITableView* layout_library_table_; + XeniaTouchLayoutLibraryTableController* layout_library_controller_; + xe::ui::ios::touch_overlay::TouchOverlayEditChromeState state_; +} + +@synthesize delegate = delegate_; + +- (instancetype)initWithFrame:(CGRect)frame { + if (!(self = [super initWithFrame:frame])) { + return nil; + } + + self.hidden = YES; + xe_apply_floating_window_chrome(self); + + title_label_ = [[UILabel alloc] initWithFrame:CGRectZero]; + title_label_.backgroundColor = [UIColor clearColor]; + title_label_.text = @"Edit"; + xe_apply_label_font(title_label_, UIFontTextStyleBody, 16.0, + UIFontWeightSemibold); + title_label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.94]; + title_label_.adjustsFontSizeToFitWidth = YES; + title_label_.minimumScaleFactor = 0.70f; + title_label_.accessibilityTraits = UIAccessibilityTraitHeader; + [self addSubview:title_label_]; + + selection_label_ = [[UILabel alloc] initWithFrame:CGRectZero]; + selection_label_.backgroundColor = [UIColor clearColor]; + xe_apply_label_font(selection_label_, UIFontTextStyleSubheadline, 15.0, + UIFontWeightSemibold); + selection_label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.82]; + selection_label_.adjustsFontSizeToFitWidth = YES; + selection_label_.minimumScaleFactor = 0.75f; + [self addSubview:selection_label_]; + + preview_label_ = [[UILabel alloc] initWithFrame:CGRectZero]; + preview_label_.backgroundColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySubtle]]; + preview_label_.layer.cornerRadius = XeniaRadiusLg; + preview_label_.layer.borderWidth = 1.0f; + preview_label_.layer.borderColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]].CGColor; + preview_label_.layer.masksToBounds = YES; + xe_apply_monospaced_label_font(preview_label_, UIFontTextStyleCaption2, 10.0, + UIFontWeightMedium); + preview_label_.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.72]; + preview_label_.numberOfLines = 0; + [self addSubview:preview_label_]; + + smaller_button_ = CreateChromeButton(@"Smaller", self, @selector(smallerButtonPressed:)); + [self addSubview:smaller_button_]; + match_size_button_ = CreateChromeButton(@"Match Size", self, @selector(matchSizeButtonPressed:)); + [self addSubview:match_size_button_]; + larger_button_ = CreateChromeButton(@"Larger", self, @selector(largerButtonPressed:)); + [self addSubview:larger_button_]; + dimmer_button_ = CreateChromeButton(@"Dimmer", self, @selector(dimmerButtonPressed:)); + [self addSubview:dimmer_button_]; + bolder_button_ = CreateChromeButton(@"Bolder", self, @selector(bolderButtonPressed:)); + [self addSubview:bolder_button_]; + action_button_ = CreateChromeButton(@"Binding", self, @selector(actionButtonPressed:)); + [self addSubview:action_button_]; + label_button_ = CreateChromeButton(@"Label", self, @selector(labelButtonPressed:)); + [self addSubview:label_button_]; + behavior_button_ = CreateChromeButton(@"Behavior: Off", self, @selector(behaviorButtonPressed:)); + behavior_button_.titleLabel.minimumScaleFactor = 0.68f; + [self addSubview:behavior_button_]; + color_button_ = CreateChromeButton(@"Color", self, @selector(colorButtonPressed:)); + [self addSubview:color_button_]; + shape_button_ = CreateChromeButton(@"Shape", self, @selector(shapeButtonPressed:)); + [self addSubview:shape_button_]; + duplicate_button_ = CreateChromeButton(@"Duplicate", self, @selector(duplicateButtonPressed:)); + [self addSubview:duplicate_button_]; + undo_button_ = CreateChromeButton(@"Undo", self, @selector(undoButtonPressed:)); + [self addSubview:undo_button_]; + redo_button_ = CreateChromeButton(@"Redo", self, @selector(redoButtonPressed:)); + [self addSubview:redo_button_]; + collapse_button_ = CreateChromeButton(@"Hide", self, @selector(collapseButtonPressed:)); + [self addSubview:collapse_button_]; + grid_button_ = CreateChromeButton(@"Grid: Off", self, @selector(gridButtonPressed:)); + grid_button_.showsMenuAsPrimaryAction = NO; + [self addSubview:grid_button_]; + add_button_ = CreateChromeButton(@"New Button", self, @selector(addButtonPressed:)); + [self addSubview:add_button_]; + delete_button_ = CreateChromeButton(@"Delete", self, @selector(deleteButtonPressed:)); + // Destructive: pinkish error tint. statusError lightened toward white for + // legibility on the dark overlay background. + [delete_button_ setTitleColor:[[XeniaTheme statusError] colorWithAlphaComponent:0.96] + forState:UIControlStateNormal]; + [self addSubview:delete_button_]; + library_button_ = CreateChromeButton(@"Layouts", self, @selector(layoutLibraryButtonPressed:)); + [self addSubview:library_button_]; + done_button_ = CreateChromeButton(@"Done", self, @selector(doneEditingButtonPressed:)); + // Done is the affirmative primary action; amber matches the in-game touch + // accent so the colour reads as "you'll see these controls when you tap". + done_button_.backgroundColor = + [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.95]; + xe_apply_button_title_font(done_button_, UIFontTextStyleSubheadline, 14.0, + UIFontWeightSemibold); + done_button_.accessibilityHint = @"Exits touch control editing."; + [done_button_ setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; + [self addSubview:done_button_]; + + layout_library_table_ = [[UITableView alloc] initWithFrame:CGRectZero + style:UITableViewStylePlain]; + layout_library_table_.hidden = YES; + layout_library_table_.backgroundColor = [UIColor clearColor]; + layout_library_table_.separatorStyle = UITableViewCellSeparatorStyleNone; + layout_library_table_.separatorInset = UIEdgeInsetsMake(0, 16, 0, 16); + layout_library_table_.rowHeight = 52.0f; + layout_library_table_.estimatedRowHeight = 52.0f; + layout_library_table_.estimatedSectionHeaderHeight = 24.0f; + layout_library_table_.estimatedSectionFooterHeight = 0.01f; + if (@available(iOS 15.0, *)) { + layout_library_table_.sectionHeaderTopPadding = 0.0f; + } + layout_library_table_.separatorColor = + [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]]; + if (@available(iOS 11.0, *)) { + layout_library_table_.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + layout_library_controller_ = [[XeniaTouchLayoutLibraryTableController alloc] init]; + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + layout_library_controller_.saveCopyHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibrarySaveCopy:unsafe_self]; + }; + layout_library_controller_.importHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibraryImport:unsafe_self]; + }; + layout_library_controller_.exportHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibraryExport:unsafe_self]; + }; + layout_library_controller_.resetHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibraryReset:unsafe_self]; + }; + layout_library_controller_.renameHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibraryRename:unsafe_self]; + }; + layout_library_controller_.deleteHandler = ^{ + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestLayoutLibraryDelete:unsafe_self]; + }; + layout_library_controller_.loadHandler = ^(NSString* local_id) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestLayoutLibraryLoad:local_id]; + }; + layout_library_table_.dataSource = layout_library_controller_; + layout_library_table_.delegate = layout_library_controller_; + layout_library_table_.indicatorStyle = UIScrollViewIndicatorStyleWhite; + [self addSubview:layout_library_table_]; + + return self; +} + +- (void)dealloc { + delegate_ = nil; + [layout_library_controller_ release]; + [layout_library_table_ release]; + [done_button_ release]; + [library_button_ release]; + [delete_button_ release]; + [add_button_ release]; + [grid_button_ release]; + [collapse_button_ release]; + [redo_button_ release]; + [undo_button_ release]; + [duplicate_button_ release]; + [shape_button_ release]; + [color_button_ release]; + [behavior_button_ release]; + [label_button_ release]; + [action_button_ release]; + [bolder_button_ release]; + [dimmer_button_ release]; + [larger_button_ release]; + [match_size_button_ release]; + [smaller_button_ release]; + [preview_label_ release]; + [selection_label_ release]; + [title_label_ release]; + [super dealloc]; +} + +- (void)setLayoutLibraryItems:(NSArray*)items + currentLayoutLocalID:(NSString*)currentLayoutLocalID { + [layout_library_controller_ setItems:items currentLayoutLocalID:currentLayoutLocalID]; + [layout_library_table_ reloadData]; +} + +- (NSString*)previewDescriptionForState { + if (!state_.has_selected_control) { + return @"Tap a control to inspect its resolved XInput output."; + } + + const auto& control = state_.selected_control; + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kMoveStick: + return + [NSString stringWithFormat:@"LX/LY move\nRadius %.0f%% • Deadzone %.0f%%", + control.activation_radius * 100.0f, control.deadzone * 100.0f]; + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + return [NSString stringWithFormat:@"RX/RY look\n%.1f pt full-scale • Look x%.2f • Y x%.2f • " + @"Hold %.0f ms", + TouchLookPointsPerFullScale() / + std::max(control.relative_look_scale, 0.1f), + control.relative_look_scale, TouchLookVerticalScale(), + TouchLookHoldSeconds() * 1000.0f]; + case xe::hid::touch::IOSTouchControlType::kPauseButton: + return @"Pause overlay control"; + case xe::hid::touch::IOSTouchControlType::kActionButton: { + xe::hid::touch::IOSTouchResolvedState sample_state = {}; + xe::hid::touch::ApplyTouchActionMapping(control, &sample_state); + NSString* buttons_text = TouchButtonMaskPreviewText(sample_state.buttons); + NSString* look_text = + control.enables_relative_look + ? [NSString stringWithFormat:@"Look x%.2f", control.relative_look_scale] + : @"Look off"; + NSString* mode_text = + control.action == xe::hid::touch::IOSTouchAction::kNone + ? @"Unused" + : (control.hold_while_captured + ? @"Primary hold" + : (xe::hid::touch::TouchControlUsesDeferredPrimaryTap(control) + ? @"Primary tap on release" + : [NSString stringWithFormat:@"Primary tap %.0f ms", + TouchButtonTapHoldSeconds() * 1000.0f])); + NSString* behavior_detail = + control.secondary_behavior.trigger != xe::hid::touch::IOSTouchInteractionTrigger::kNone + ? [NSString + stringWithFormat:@"%@ • %@", + TouchInteractionBehaviorSummaryText( + control.secondary_behavior), + control.secondary_behavior.enables_relative_look + ? [NSString stringWithFormat:@"Look x%.2f", + control.secondary_behavior + .relative_look_scale] + : @"Look off"] + : @"Behavior: Off"; + return [NSString stringWithFormat:@"Primary %@ • LT/RT %u/%u • %@\n%@\n%@", buttons_text, + sample_state.left_trigger, sample_state.right_trigger, + look_text, mode_text, behavior_detail]; + } + } + return @"XInput preview unavailable."; +} + +- (BOOL)selectedControlSupportsLookScaleTuning { + if (!state_.has_selected_control) { + return NO; + } + const auto& control = state_.selected_control; + return control.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone || + control.enables_relative_look; +} + +- (float)selectedControlLookScale { + if (!state_.has_selected_control) { + return 1.0f; + } + return std::clamp(state_.selected_control.relative_look_scale, 0.25f, 4.0f); +} + +- (void)applyState:(const xe::ui::ios::touch_overlay::TouchOverlayEditChromeState&)state { + state_ = state; + + if (state_.showing_layout_library) { + const BOOL show_edit_chrome = state_.editing_enabled; + const BOOL show_minimized_header_only = show_edit_chrome && state_.minimized; + title_label_.text = @"Layouts"; + title_label_.hidden = !show_edit_chrome; + selection_label_.text = @"Apply a layout or manage saved copies"; + selection_label_.hidden = !show_edit_chrome || show_minimized_header_only; + preview_label_.hidden = YES; + smaller_button_.hidden = YES; + match_size_button_.hidden = YES; + larger_button_.hidden = YES; + dimmer_button_.hidden = YES; + bolder_button_.hidden = YES; + action_button_.hidden = YES; + label_button_.hidden = YES; + behavior_button_.hidden = YES; + color_button_.hidden = YES; + shape_button_.hidden = YES; + duplicate_button_.hidden = YES; + undo_button_.hidden = YES; + redo_button_.hidden = YES; + collapse_button_.hidden = YES; + grid_button_.hidden = YES; + add_button_.hidden = YES; + delete_button_.hidden = YES; + library_button_.hidden = !show_edit_chrome; + done_button_.hidden = !show_edit_chrome; + [library_button_ setTitle:@"Back" forState:UIControlStateNormal]; + layout_library_table_.hidden = !show_edit_chrome || show_minimized_header_only; + [self setNeedsLayout]; + return; + } + + NSString* selection_text = @"Tap a control"; + NSString* chrome_title_text = @"Edit"; + BOOL has_selection = NO; + BOOL can_remap_action = NO; + BOOL can_edit_behavior = NO; + NSString* action_button_title = @"Binding"; + NSString* behavior_button_title = @"Behavior: Off"; + NSString* color_button_title = @"Style"; + NSString* duplicate_button_title = @"More"; + + if (state_.has_selected_control) { + const auto& control = state_.selected_control; + NSString* label_text = XeniaTouchConfiguredControlLabelText(control, YES); + chrome_title_text = state_.minimized + ? [NSString stringWithFormat:@"Edit %@", label_text ?: @"Control"] + : @"Edit"; + selection_text = [NSString stringWithFormat:@"Selected • %@", label_text ?: @"Control"]; + has_selection = YES; + can_remap_action = control.type == xe::hid::touch::IOSTouchControlType::kActionButton; + can_edit_behavior = + can_remap_action || control.type == xe::hid::touch::IOSTouchControlType::kMoveStick; + NSString* action_name = + [NSString stringWithUTF8String:xe::hid::touch::IOSTouchActionDisplayName(control.action)]; + action_button_title = can_remap_action ? [NSString stringWithFormat:@"Binding: %@", action_name] + : @"Binding Locked"; + if (can_edit_behavior) { + behavior_button_title = TouchInteractionBehaviorSummaryText(control.secondary_behavior); + } + } + + NSString* orientation_chip = state_.editing_portrait ? @"Portrait" : @"Landscape"; + chrome_title_text = [NSString stringWithFormat:@"%@ · %@", chrome_title_text, orientation_chip]; + + const BOOL show_edit_chrome = state_.editing_enabled; + const BOOL show_add_button = show_edit_chrome; + const BOOL show_minimized_header_only = show_edit_chrome && state_.minimized; + title_label_.text = chrome_title_text; + title_label_.hidden = !show_edit_chrome; + selection_label_.hidden = !show_edit_chrome || show_minimized_header_only; + preview_label_.hidden = !show_edit_chrome || show_minimized_header_only || !can_remap_action; + smaller_button_.hidden = YES; + match_size_button_.hidden = YES; + larger_button_.hidden = YES; + dimmer_button_.hidden = YES; + bolder_button_.hidden = YES; + action_button_.hidden = !show_edit_chrome || show_minimized_header_only || !can_remap_action; + label_button_.hidden = YES; + behavior_button_.hidden = !show_edit_chrome || show_minimized_header_only || !can_edit_behavior; + color_button_.hidden = !show_edit_chrome || show_minimized_header_only || !has_selection; + shape_button_.hidden = YES; + duplicate_button_.hidden = !show_edit_chrome || show_minimized_header_only || !has_selection; + undo_button_.hidden = !show_edit_chrome || show_minimized_header_only; + redo_button_.hidden = !show_edit_chrome || show_minimized_header_only; + collapse_button_.hidden = !show_edit_chrome; + grid_button_.hidden = !show_edit_chrome; + add_button_.hidden = !show_add_button || show_minimized_header_only; + delete_button_.hidden = YES; + library_button_.hidden = !show_edit_chrome; + done_button_.hidden = !show_edit_chrome; + + selection_label_.text = selection_text; + preview_label_.text = [self previewDescriptionForState]; + smaller_button_.enabled = NO; + match_size_button_.enabled = NO; + larger_button_.enabled = NO; + dimmer_button_.enabled = NO; + bolder_button_.enabled = NO; + smaller_button_.alpha = 0.0; + match_size_button_.alpha = 0.0; + larger_button_.alpha = 0.0; + dimmer_button_.alpha = 0.0; + bolder_button_.alpha = 0.0; + action_button_.enabled = can_remap_action; + action_button_.alpha = can_remap_action ? 1.0 : 0.45; + [action_button_ setTitle:action_button_title forState:UIControlStateNormal]; + label_button_.enabled = NO; + label_button_.alpha = 0.0; + behavior_button_.enabled = can_edit_behavior; + behavior_button_.alpha = can_edit_behavior ? 1.0 : 0.45; + [behavior_button_ setTitle:behavior_button_title forState:UIControlStateNormal]; + color_button_.enabled = has_selection; + color_button_.alpha = has_selection ? 1.0 : 0.45; + [color_button_ setTitle:color_button_title forState:UIControlStateNormal]; + shape_button_.enabled = NO; + shape_button_.alpha = 0.0; + duplicate_button_.enabled = has_selection; + duplicate_button_.alpha = has_selection ? 1.0 : 0.45; + [duplicate_button_ setTitle:duplicate_button_title forState:UIControlStateNormal]; + undo_button_.enabled = state_.can_undo; + undo_button_.alpha = undo_button_.enabled ? 1.0 : 0.45; + [undo_button_ setTitle:@"Undo" forState:UIControlStateNormal]; + redo_button_.enabled = state_.can_redo; + redo_button_.alpha = redo_button_.enabled ? 1.0 : 0.45; + [redo_button_ setTitle:@"Redo" forState:UIControlStateNormal]; + [collapse_button_ setTitle:(state_.minimized ? @"Edit" : @"Hide") forState:UIControlStateNormal]; + collapse_button_.enabled = show_edit_chrome; + collapse_button_.alpha = show_edit_chrome ? 1.0 : 0.45; + [grid_button_ setTitle:@"Grid" forState:UIControlStateNormal]; + add_button_.enabled = state_.editing_enabled; + add_button_.alpha = state_.editing_enabled ? 1.0 : 0.45; + [add_button_ setTitle:@"New" forState:UIControlStateNormal]; + [library_button_ setTitle:@"Layouts" forState:UIControlStateNormal]; + grid_button_.enabled = state_.editing_enabled; + grid_button_.alpha = state_.editing_enabled ? 1.0 : 0.45; + grid_button_.backgroundColor = + state_.grid_enabled + ? [[XeniaTheme touchTintAmber] colorWithAlphaComponent:0.95] + : [[UIColor whiteColor] colorWithAlphaComponent:[XeniaTheme opacitySoft]]; + [grid_button_ + setTitleColor:(state_.grid_enabled + ? [UIColor blackColor] + : [[UIColor whiteColor] colorWithAlphaComponent:0.94]) + forState:UIControlStateNormal]; + delete_button_.enabled = NO; + delete_button_.alpha = 0.0; + layout_library_table_.hidden = YES; + [self refreshPickerMenus]; + [self setNeedsLayout]; +} + +- (NSArray*>*)bodyRows { + return @[ + @[ undo_button_, redo_button_ ], + @[ action_button_, behavior_button_ ], + @[ color_button_, duplicate_button_ ], + @[ add_button_ ], + ]; +} + +- (CGFloat)preferredHeightForWidth:(CGFloat)width + availableHeight:(CGFloat)availableHeight + margin:(CGFloat)margin { + const BOOL chrome_minimized = state_.minimized; + const BOOL showing_layout_library = state_.showing_layout_library && !chrome_minimized; + if (showing_layout_library) { + return MAX(50.0f, MIN(availableHeight - margin * 2.0f, availableHeight * 0.78f)); + } + + CGFloat chrome_height = chrome_minimized ? 50.0f : (12.0f + 36.0f + 8.0f + 20.0f + 10.0f); + if (!chrome_minimized) { + for (NSArray* row_buttons in [self bodyRows]) { + BOOL has_visible_button = NO; + for (UIButton* button in row_buttons) { + if (!button.hidden) { + has_visible_button = YES; + break; + } + } + if (has_visible_button) { + chrome_height += kButtonHeight + kRowGap; + } + } + if (!preview_label_.hidden) { + chrome_height += + TouchEditPreviewHeightForLabel(preview_label_, width - kChromeInset * 2.0f) + kRowGap; + } + } + return chrome_height + 10.0f; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + const CGFloat local_width = CGRectGetWidth(self.bounds); + done_button_.frame = CGRectMake(local_width - kChromeInset - kDoneWidth, 8.0f, kDoneWidth, 34.0f); + library_button_.frame = CGRectMake(CGRectGetMinX(done_button_.frame) - kHeaderGap - kLayoutsWidth, + kHeaderTop, kLayoutsWidth, kHeaderButtonHeight); + grid_button_.frame = CGRectMake(CGRectGetMinX(library_button_.frame) - kHeaderGap - kGridWidth, + kHeaderTop, kGridWidth, kHeaderButtonHeight); + collapse_button_.frame = + CGRectMake(CGRectGetMinX(grid_button_.frame) - kHeaderGap - kCollapseWidth, kHeaderTop, + kCollapseWidth, kHeaderButtonHeight); + title_label_.frame = CGRectMake( + kChromeInset, 12.0f, CGRectGetMinX(collapse_button_.frame) - kChromeInset - 8.0f, 22.0f); + + if (state_.minimized) { + selection_label_.frame = CGRectZero; + action_button_.frame = CGRectZero; + label_button_.frame = CGRectZero; + behavior_button_.frame = CGRectZero; + smaller_button_.frame = CGRectZero; + match_size_button_.frame = CGRectZero; + larger_button_.frame = CGRectZero; + dimmer_button_.frame = CGRectZero; + bolder_button_.frame = CGRectZero; + color_button_.frame = CGRectZero; + shape_button_.frame = CGRectZero; + duplicate_button_.frame = CGRectZero; + undo_button_.frame = CGRectZero; + redo_button_.frame = CGRectZero; + add_button_.frame = CGRectZero; + delete_button_.frame = CGRectZero; + preview_label_.frame = CGRectZero; + layout_library_table_.frame = CGRectZero; + return; + } + + CGFloat current_y = CGRectGetMaxY(done_button_.frame) + 10.0f; + selection_label_.frame = + CGRectMake(kChromeInset, current_y, local_width - kChromeInset * 2.0f, 20.0f); + current_y = CGRectGetMaxY(selection_label_.frame) + 10.0f; + + if (state_.showing_layout_library) { + layout_library_table_.frame = + CGRectMake(kChromeInset, current_y, local_width - kChromeInset * 2.0f, + CGRectGetHeight(self.bounds) - current_y - 10.0f); + undo_button_.frame = CGRectZero; + redo_button_.frame = CGRectZero; + preview_label_.frame = CGRectZero; + return; + } + + for (NSArray* row_buttons in [self bodyRows]) { + current_y = LayoutVisibleButtonsRow(current_y, kChromeInset, local_width - kChromeInset * 2.0f, + kButtonHeight, kRowGap, row_buttons); + } + + if (!preview_label_.hidden) { + const CGFloat preview_height = + TouchEditPreviewHeightForLabel(preview_label_, local_width - kChromeInset * 2.0f); + preview_label_.frame = + CGRectMake(kChromeInset, current_y, local_width - kChromeInset * 2.0f, preview_height); + } else { + preview_label_.frame = CGRectZero; + } + layout_library_table_.frame = CGRectZero; +} + +- (UIView*)interactiveHitTestForOverlayPoint:(CGPoint)point + event:(UIEvent*)event + inView:(UIView*)overlayView { + if (self.hidden || self.alpha <= 0.01f || !self.userInteractionEnabled) { + return nil; + } + for (UIView* subview in [self.subviews reverseObjectEnumerator]) { + if (subview.hidden || subview.alpha <= 0.01f || !subview.userInteractionEnabled) { + continue; + } + CGPoint local_point = [subview convertPoint:point fromView:overlayView]; + UIView* hit = [subview hitTest:local_point withEvent:event]; + if (hit) { + return hit; + } + } + CGPoint chrome_point = [self convertPoint:point fromView:overlayView]; + if (!layout_library_table_.hidden && + CGRectContainsPoint(layout_library_table_.frame, chrome_point)) { + return layout_library_table_; + } + return nil; +} + +- (UIMenu*)bindingMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + const auto& control = state_.selected_control; + const xe::hid::touch::IOSTouchControlType control_type = control.type; + const xe::hid::touch::IOSTouchAction current_action = control.action; + + std::vector action_choices; + if (control_type == xe::hid::touch::IOSTouchControlType::kMoveStick || + control_type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + action_choices = {xe::hid::touch::IOSTouchAction::kMove, xe::hid::touch::IOSTouchAction::kLook}; + } else if (control_type == xe::hid::touch::IOSTouchControlType::kActionButton) { + action_choices.assign(xe::hid::touch::kIOSTouchEditableActions.begin(), + xe::hid::touch::kIOSTouchEditableActions.end()); + } else { + return nil; + } + + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = + [NSMutableArray arrayWithCapacity:action_choices.size()]; + for (xe::hid::touch::IOSTouchAction action : action_choices) { + NSString* title = + [NSString stringWithUTF8String:xe::hid::touch::IOSTouchActionDisplayName(action)]; + UIAction* binding_action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestAction:action]; + }]; + binding_action.state = action == current_action ? UIMenuElementStateOn : UIMenuElementStateOff; + [children addObject:binding_action]; + } + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)labelMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + const auto& control = state_.selected_control; + const BOOL label_hidden = control.label_hidden; + const bool has_custom_label = xe::hid::touch::IOSTouchControlHasCustomLabel(control); + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray arrayWithCapacity:3]; + + UIAction* rename_action = [UIAction + actionWithTitle:@"Rename…" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestRenameLabel:unsafe_self]; + }]; + [children addObject:rename_action]; + + UIAction* reset_action = [UIAction + actionWithTitle:@"Use Default Label" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestResetLabel:unsafe_self]; + }]; + if (!has_custom_label) { + reset_action.attributes = UIMenuElementAttributesDisabled; + } + [children addObject:reset_action]; + + UIAction* show_action = + [UIAction actionWithTitle:@"Show Label" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestLabelHidden:NO]; + }]; + show_action.state = label_hidden ? UIMenuElementStateOff : UIMenuElementStateOn; + [children addObject:show_action]; + + UIAction* hide_action = + [UIAction actionWithTitle:@"Hide Label" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestLabelHidden:YES]; + }]; + hide_action.state = label_hidden ? UIMenuElementStateOn : UIMenuElementStateOff; + [children addObject:hide_action]; + + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)behaviorMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + const auto& control = state_.selected_control; + const bool is_action_button = control.type == xe::hid::touch::IOSTouchControlType::kActionButton; + const bool is_move_stick = control.type == xe::hid::touch::IOSTouchControlType::kMoveStick; + if (!is_action_button && !is_move_stick) { + return nil; + } + + const auto& behavior = control.secondary_behavior; + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray array]; + + NSMutableArray* trigger_children = [NSMutableArray array]; + std::vector triggers; + if (is_move_stick) { + triggers = {xe::hid::touch::IOSTouchInteractionTrigger::kNone, + xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTapForward}; + } else { + triggers = {xe::hid::touch::IOSTouchInteractionTrigger::kNone, + xe::hid::touch::IOSTouchInteractionTrigger::kHold, + xe::hid::touch::IOSTouchInteractionTrigger::kHoldDrag, + xe::hid::touch::IOSTouchInteractionTrigger::kDoubleTap}; + } + for (xe::hid::touch::IOSTouchInteractionTrigger trigger : triggers) { + NSString* title = [NSString + stringWithUTF8String:xe::hid::touch::IOSTouchInteractionTriggerDisplayName(trigger)]; + UIAction* trigger_action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestBehaviorTrigger:trigger]; + }]; + trigger_action.state = + trigger == behavior.trigger ? UIMenuElementStateOn : UIMenuElementStateOff; + if (@available(iOS 16.0, *)) { + trigger_action.attributes = UIMenuElementAttributesKeepsMenuPresented; + } + [trigger_children addObject:trigger_action]; + } + [children addObject:[UIMenu menuWithTitle:@"Trigger" children:trigger_children]]; + + NSMutableArray* output_children = [NSMutableArray array]; + for (xe::hid::touch::IOSTouchAction action : xe::hid::touch::kIOSTouchEditableActions) { + NSString* title = + [NSString stringWithUTF8String:xe::hid::touch::IOSTouchActionDisplayName(action)]; + UIAction* output_action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestBehaviorAction:action]; + }]; + output_action.state = action == behavior.action ? UIMenuElementStateOn : UIMenuElementStateOff; + [output_children addObject:output_action]; + } + [children addObject:[UIMenu menuWithTitle:@"Output" children:output_children]]; + + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)tintMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + const xe::hid::touch::IOSTouchTintStyle current_tint = state_.selected_control.tint_style; + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = + [NSMutableArray arrayWithCapacity:xe::hid::touch::kIOSTouchEditableTintStyles.size()]; + for (xe::hid::touch::IOSTouchTintStyle tint_style : xe::hid::touch::kIOSTouchEditableTintStyles) { + NSString* title = + [NSString stringWithUTF8String:xe::hid::touch::IOSTouchTintStyleDisplayName(tint_style)]; + UIAction* tint_action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestTintStyle:tint_style]; + }]; + tint_action.state = tint_style == current_tint ? UIMenuElementStateOn : UIMenuElementStateOff; + [children addObject:tint_action]; + } + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)lookScaleMenuForSelectedControl { + if (![self selectedControlSupportsLookScaleTuning] || !state_.has_selected_control) { + return nil; + } + + const auto& control = state_.selected_control; + const bool is_look_zone = control.type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone; + const float current_scale = [self selectedControlLookScale]; + const float* choices = is_look_zone ? kLookZoneScaleChoices : kRelativeLookScaleChoices; + const size_t choice_count = + is_look_zone ? std::size(kLookZoneScaleChoices) : std::size(kRelativeLookScaleChoices); + + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray arrayWithCapacity:choice_count]; + for (size_t index = 0; index < choice_count; ++index) { + const float scale = choices[index]; + NSString* title = [NSString stringWithFormat:@"%.2fx", scale]; + UIAction* scale_action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestLookScale:scale]; + }]; + scale_action.state = + std::abs(scale - current_scale) < 0.001f ? UIMenuElementStateOn : UIMenuElementStateOff; + if (@available(iOS 16.0, *)) { + scale_action.attributes = UIMenuElementAttributesKeepsMenuPresented; + } + [children addObject:scale_action]; + } + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)shapeMenuForSelectedControl { + if (!state_.has_selected_control || + state_.selected_control.type != xe::hid::touch::IOSTouchControlType::kActionButton) { + return nil; + } + + const xe::hid::touch::IOSTouchControlShape current_shape = state_.selected_control.shape; + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = + [NSMutableArray arrayWithCapacity:sizeof(kEditShapeChoices) / sizeof(kEditShapeChoices[0])]; + for (xe::hid::touch::IOSTouchControlShape shape : kEditShapeChoices) { + UIAction* shape_action = [UIAction + actionWithTitle:TouchControlShapeDisplayText(shape) + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self didRequestShape:shape]; + }]; + shape_action.state = shape == current_shape ? UIMenuElementStateOn : UIMenuElementStateOff; + [children addObject:shape_action]; + } + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)styleMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + + const auto& control = state_.selected_control; + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray array]; + + UIMenu* tint_menu = [self tintMenuForSelectedControl]; + if (tint_menu.children.count) { + [children addObject:[UIMenu menuWithTitle:@"Tint" children:tint_menu.children]]; + } + + UIMenu* look_scale_menu = [self lookScaleMenuForSelectedControl]; + if (look_scale_menu.children.count) { + [children addObject:[UIMenu menuWithTitle:@"Look Sensitivity" + children:look_scale_menu.children]]; + } + + if (control.type == xe::hid::touch::IOSTouchControlType::kActionButton) { + UIMenu* shape_menu = [self shapeMenuForSelectedControl]; + if (shape_menu.children.count) { + [children addObject:[UIMenu menuWithTitle:@"Shape" children:shape_menu.children]]; + } + } + + NSString* opacity_title = + [NSString stringWithFormat:@"Opacity %.0f%%", control.visual_opacity * 100.0f]; + UIAction* opacity_info = [UIAction actionWithTitle:opacity_title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler){ + }]; + opacity_info.attributes = UIMenuElementAttributesDisabled; + [children addObject:opacity_info]; + + UIAction* dimmer_action = [UIAction + actionWithTitle:@"Dimmer" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestDimmerControl:unsafe_self]; + }]; + UIAction* bolder_action = [UIAction + actionWithTitle:@"Bolder" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestBolderControl:unsafe_self]; + }]; + if (@available(iOS 16.0, *)) { + dimmer_action.attributes = UIMenuElementAttributesKeepsMenuPresented; + bolder_action.attributes = UIMenuElementAttributesKeepsMenuPresented; + } + [children addObject:dimmer_action]; + [children addObject:bolder_action]; + + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)moreMenuForSelectedControl { + if (!state_.has_selected_control) { + return nil; + } + + const auto& control = state_.selected_control; + const BOOL can_duplicate = control.type == xe::hid::touch::IOSTouchControlType::kActionButton; + const BOOL can_delete = state_.control_count > 1; + const BOOL can_match_size = state_.can_match_selected_size; + + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray array]; + + UIMenu* label_menu = [self labelMenuForSelectedControl]; + if (label_menu.children.count) { + [children addObject:[UIMenu menuWithTitle:@"Label" children:label_menu.children]]; + } + + if (can_duplicate) { + UIAction* duplicate_action = + [UIAction actionWithTitle:@"Duplicate" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ + touchOverlayEditChromeDidRequestDuplicateControl:unsafe_self]; + }]; + [children addObject:duplicate_action]; + } + + if (can_match_size) { + UIAction* match_action = + [UIAction actionWithTitle:@"Match Nearest Size" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ + touchOverlayEditChromeDidRequestMatchNearestSize:unsafe_self]; + }]; + [children addObject:match_action]; + } + + UIAction* mirror_action = [UIAction + actionWithTitle:@"Mirror Horizontally" + image:[UIImage systemImageNamed: + @"arrow.left.and.right.righttriangle.left.righttriangle.right"] + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestMirrorControl:unsafe_self]; + }]; + [children addObject:mirror_action]; + + NSString* copy_layout_title = + state_.editing_portrait ? @"Copy Layout from Landscape" : @"Copy Layout from Portrait"; + const BOOL from_landscape = state_.editing_portrait; + UIAction* copy_layout_action = + [UIAction actionWithTitle:copy_layout_title + image:[UIImage systemImageNamed:@"square.on.square"] + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestCopyLayoutFromLandscape:from_landscape]; + }]; + [children addObject:copy_layout_action]; + + if (control.type == xe::hid::touch::IOSTouchControlType::kMoveStick) { + NSString* dpad_title = control.move_with_dpad_ring ? @"Disable D-Pad Ring" : @"Add D-Pad Ring"; + UIAction* dpad_ring_action = + [UIAction actionWithTitle:dpad_title + image:[UIImage systemImageNamed:@"dpad"] + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ + touchOverlayEditChromeDidRequestToggleMoveDpadRing:unsafe_self]; + }]; + [children addObject:dpad_ring_action]; + } + + UIAction* delete_action = [UIAction + actionWithTitle:@"Delete" + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChromeDidRequestDeleteControl:unsafe_self]; + }]; + delete_action.attributes = UIMenuElementAttributesDestructive; + if (!can_delete) { + delete_action.attributes = static_cast( + delete_action.attributes | UIMenuElementAttributesDisabled); + } + [children addObject:delete_action]; + + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (UIMenu*)addControlMenu { + if (!state_.editing_enabled) { + return nil; + } + + __unsafe_unretained XeniaTouchOverlayEditChromeIOS* unsafe_self = self; + NSMutableArray* children = [NSMutableArray array]; + const BOOL can_add_control = state_.control_count < xe::hid::touch::kMaxIOSTouchControls; + + auto append_action = ^(NSString* title, xe::hid::touch::IOSTouchControlType type, BOOL enabled) { + UIAction* action = + [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(__unused UIAction* action_handler) { + [unsafe_self->delegate_ touchOverlayEditChrome:unsafe_self + didRequestAddControlOfType:type]; + }]; + if (!enabled || !can_add_control) { + action.attributes = UIMenuElementAttributesDisabled; + } + [children addObject:action]; + }; + + append_action(@"Action Button", xe::hid::touch::IOSTouchControlType::kActionButton, YES); + append_action(@"Move Stick", xe::hid::touch::IOSTouchControlType::kMoveStick, + !state_.layout_contains_move_stick); + append_action(@"Look Zone", xe::hid::touch::IOSTouchControlType::kLookSwipeZone, + !state_.layout_contains_look_zone); + append_action(@"Pause Button", xe::hid::touch::IOSTouchControlType::kPauseButton, + !state_.layout_contains_pause_button); + + return [UIMenu menuWithTitle:@"" children:children]; +} + +- (void)refreshPickerMenus { + ConfigureMenuButton(action_button_, [self bindingMenuForSelectedControl]); + ConfigureMenuButton(label_button_, nil); + ConfigureMenuButton(behavior_button_, [self behaviorMenuForSelectedControl]); + ConfigureMenuButton(color_button_, [self styleMenuForSelectedControl]); + ConfigureMenuButton(shape_button_, nil); + ConfigureMenuButton(duplicate_button_, [self moreMenuForSelectedControl]); + ConfigureMenuButton(add_button_, [self addControlMenu]); +} + +- (void)doneEditingButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestDoneEditing:self]; +} + +- (void)smallerButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestSmallerControl:self]; +} + +- (void)matchSizeButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestMatchNearestSize:self]; +} + +- (void)largerButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestLargerControl:self]; +} + +- (void)dimmerButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestDimmerControl:self]; +} + +- (void)bolderButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestBolderControl:self]; +} + +- (void)actionButtonPressed:(UIButton*)__unused sender { + if (action_button_.showsMenuAsPrimaryAction && action_button_.menu != nil) { + return; + } + [delegate_ touchOverlayEditChromeDidRequestCycleAction:self]; +} + +- (void)labelButtonPressed:(UIButton*)__unused sender { + if (label_button_.showsMenuAsPrimaryAction && label_button_.menu != nil) { + return; + } + [delegate_ touchOverlayEditChromeDidRequestRenameLabel:self]; +} + +- (void)behaviorButtonPressed:(UIButton*)__unused sender { + if (behavior_button_.showsMenuAsPrimaryAction && behavior_button_.menu != nil) { + return; + } +} + +- (void)colorButtonPressed:(UIButton*)__unused sender { + if (color_button_.showsMenuAsPrimaryAction && color_button_.menu != nil) { + return; + } + [delegate_ touchOverlayEditChromeDidRequestCycleTint:self]; +} + +- (void)shapeButtonPressed:(UIButton*)__unused sender { + if (shape_button_.showsMenuAsPrimaryAction && shape_button_.menu != nil) { + return; + } + [delegate_ touchOverlayEditChromeDidRequestCycleShape:self]; +} + +- (void)duplicateButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestDuplicateControl:self]; +} + +- (void)undoButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestUndo:self]; +} + +- (void)redoButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestRedo:self]; +} + +- (void)collapseButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestCollapse:self]; +} + +- (void)gridButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestToggleGrid:self]; +} + +- (void)addButtonPressed:(UIButton*)__unused sender { + if (add_button_.showsMenuAsPrimaryAction && add_button_.menu != nil) { + return; + } + [delegate_ touchOverlayEditChromeDidRequestAddDefaultControl:self]; +} + +- (void)deleteButtonPressed:(UIButton*)__unused sender { + [delegate_ touchOverlayEditChromeDidRequestDeleteControl:self]; +} + +- (void)layoutLibraryButtonPressed:(UIButton*)__unused sender { + if (state_.showing_layout_library) { + [delegate_ touchOverlayEditChromeDidRequestHideLayoutLibrary:self]; + return; + } + [delegate_ touchOverlayEditChromeDidRequestLayoutLibrary:self]; +} + +@end diff --git a/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.h b/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.h new file mode 100644 index 000000000..a88303e1d --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.h @@ -0,0 +1,58 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_HISTORY_IOS_H_ +#define XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_HISTORY_IOS_H_ + +#include +#include +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe::ui::ios::touch_overlay { + +class TouchOverlayEditHistoryIOS { + public: + void Reset(); + void SeedIfNeeded(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier); + void BeginChange(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier); + bool FinishChange(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier); + void CancelChange(); + + bool IsChangeActive() const { return change_active_; } + bool CanUndo() const; + bool CanRedo() const; + bool Undo(xe::hid::touch::IOSTouchLayoutModel* layout, + std::string* selected_control_identifier); + bool Redo(xe::hid::touch::IOSTouchLayoutModel* layout, + std::string* selected_control_identifier); + + private: + struct Entry { + xe::hid::touch::IOSTouchLayoutModel layout; + std::string selected_control_identifier; + }; + + static bool EntriesEqual(const Entry& left, const Entry& right); + void Trim(); + void PushUndoEntry(Entry entry); + + bool change_active_ = false; + Entry pending_change_; + std::vector undo_history_; + std::vector redo_history_; +}; + +} // namespace xe::ui::ios::touch_overlay + +#endif // XENIA_UI_IOS_TOUCH_TOUCH_OVERLAY_EDIT_HISTORY_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.mm b/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.mm new file mode 100644 index 000000000..40c031567 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_edit_history_ios.mm @@ -0,0 +1,187 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/touch/touch_overlay_edit_history_ios.h" + +#include +#include + +namespace xe::ui::ios::touch_overlay { +namespace { + +constexpr size_t kTouchEditHistoryLimit = 64; + +bool TouchFloatNear(float left, float right) { return std::abs(left - right) < 0.0005f; } + +bool TouchRectsEqual(const xe::hid::touch::IOSTouchRect& left, + const xe::hid::touch::IOSTouchRect& right) { + return TouchFloatNear(left.x, right.x) && TouchFloatNear(left.y, right.y) && + TouchFloatNear(left.width, right.width) && TouchFloatNear(left.height, right.height); +} + +bool TouchInteractionBehaviorsEqual(const xe::hid::touch::IOSTouchInteractionBehavior& left, + const xe::hid::touch::IOSTouchInteractionBehavior& right) { + return left.trigger == right.trigger && left.action == right.action && + left.enables_relative_look == right.enables_relative_look && + TouchFloatNear(left.relative_look_scale, right.relative_look_scale) && + TouchFloatNear(left.hold_seconds, right.hold_seconds) && + TouchFloatNear(left.drag_threshold_points, right.drag_threshold_points); +} + +bool TouchControlDefinitionsEqual(const xe::hid::touch::IOSTouchControlDefinition& left, + const xe::hid::touch::IOSTouchControlDefinition& right) { + return left.identifier == right.identifier && left.label == right.label && + left.label_uses_default == right.label_uses_default && + left.label_hidden == right.label_hidden && left.type == right.type && + left.action == right.action && left.shape == right.shape && + TouchRectsEqual(left.normalized_frame, right.normalized_frame) && + left.has_portrait_frame == right.has_portrait_frame && + (!left.has_portrait_frame || + TouchRectsEqual(left.portrait_normalized_frame, right.portrait_normalized_frame)) && + TouchFloatNear(left.deadzone, right.deadzone) && + TouchFloatNear(left.activation_radius, right.activation_radius) && + TouchFloatNear(left.visual_opacity, right.visual_opacity) && + left.tint_style == right.tint_style && + left.hold_while_captured == right.hold_while_captured && + left.enables_relative_look == right.enables_relative_look && + TouchFloatNear(left.relative_look_scale, right.relative_look_scale) && + TouchInteractionBehaviorsEqual(left.secondary_behavior, right.secondary_behavior) && + left.capture_priority == right.capture_priority && + left.mapped_buttons == right.mapped_buttons && + left.mapped_left_trigger == right.mapped_left_trigger && + left.mapped_right_trigger == right.mapped_right_trigger; +} + +bool TouchLayoutModelsEqual(const xe::hid::touch::IOSTouchLayoutModel& left, + const xe::hid::touch::IOSTouchLayoutModel& right) { + if (left.layout_id != right.layout_id || left.display_name != right.display_name || + left.author != right.author || left.base_template != right.base_template || + left.controls.size() != right.controls.size()) { + return false; + } + for (size_t index = 0; index < left.controls.size(); ++index) { + if (!TouchControlDefinitionsEqual(left.controls[index], right.controls[index])) { + return false; + } + } + return true; +} + +} // namespace + +void TouchOverlayEditHistoryIOS::Reset() { + change_active_ = false; + pending_change_ = Entry{}; + undo_history_.clear(); + redo_history_.clear(); +} + +void TouchOverlayEditHistoryIOS::SeedIfNeeded(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier) { + if (!undo_history_.empty()) { + return; + } + undo_history_.push_back(Entry{layout, std::move(selected_control_identifier)}); +} + +void TouchOverlayEditHistoryIOS::BeginChange(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier) { + if (change_active_) { + return; + } + pending_change_ = Entry{layout, std::move(selected_control_identifier)}; + change_active_ = true; +} + +bool TouchOverlayEditHistoryIOS::FinishChange(const xe::hid::touch::IOSTouchLayoutModel& layout, + std::string selected_control_identifier) { + if (!change_active_) { + return false; + } + change_active_ = false; + + Entry previous = std::move(pending_change_); + pending_change_ = Entry{}; + Entry current{layout, std::move(selected_control_identifier)}; + if (TouchLayoutModelsEqual(previous.layout, current.layout)) { + return false; + } + + if (undo_history_.empty() || !EntriesEqual(undo_history_.back(), previous)) { + PushUndoEntry(std::move(previous)); + } + if (!undo_history_.empty() && EntriesEqual(undo_history_.back(), current)) { + redo_history_.clear(); + return true; + } + + PushUndoEntry(std::move(current)); + redo_history_.clear(); + Trim(); + return true; +} + +void TouchOverlayEditHistoryIOS::CancelChange() { + change_active_ = false; + pending_change_ = Entry{}; +} + +bool TouchOverlayEditHistoryIOS::CanUndo() const { return undo_history_.size() > 1; } + +bool TouchOverlayEditHistoryIOS::CanRedo() const { return !redo_history_.empty(); } + +bool TouchOverlayEditHistoryIOS::Undo(xe::hid::touch::IOSTouchLayoutModel* layout, + std::string* selected_control_identifier) { + if (!CanUndo() || !layout || !selected_control_identifier) { + return false; + } + redo_history_.push_back(std::move(undo_history_.back())); + undo_history_.pop_back(); + const Entry& target = undo_history_.back(); + *layout = target.layout; + *selected_control_identifier = target.selected_control_identifier; + Trim(); + return true; +} + +bool TouchOverlayEditHistoryIOS::Redo(xe::hid::touch::IOSTouchLayoutModel* layout, + std::string* selected_control_identifier) { + if (!CanRedo() || !layout || !selected_control_identifier) { + return false; + } + Entry target = std::move(redo_history_.back()); + redo_history_.pop_back(); + *layout = target.layout; + *selected_control_identifier = target.selected_control_identifier; + undo_history_.push_back(std::move(target)); + Trim(); + return true; +} + +bool TouchOverlayEditHistoryIOS::EntriesEqual(const Entry& left, const Entry& right) { + return left.selected_control_identifier == right.selected_control_identifier && + TouchLayoutModelsEqual(left.layout, right.layout); +} + +void TouchOverlayEditHistoryIOS::Trim() { + if (undo_history_.size() > kTouchEditHistoryLimit) { + const size_t remove_count = undo_history_.size() - kTouchEditHistoryLimit; + undo_history_.erase(undo_history_.begin(), undo_history_.begin() + remove_count); + } + if (redo_history_.size() > kTouchEditHistoryLimit) { + const size_t remove_count = redo_history_.size() - kTouchEditHistoryLimit; + redo_history_.erase(redo_history_.begin(), redo_history_.begin() + remove_count); + } +} + +void TouchOverlayEditHistoryIOS::PushUndoEntry(Entry entry) { + undo_history_.push_back(std::move(entry)); +} + +} // namespace xe::ui::ios::touch_overlay diff --git a/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.h b/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.h new file mode 100644 index 000000000..0cb930d0a --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.h @@ -0,0 +1,99 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_OVERLAY_GEOMETRY_IOS_H_ +#define XENIA_UI_IOS_TOUCH_OVERLAY_GEOMETRY_IOS_H_ + +#import + +#include +#include +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace ui { + +struct AxisAlignmentSnapResult { + float origin = 0.0f; + float guide = 0.0f; + float snapped_origin = 0.0f; + bool active = false; +}; + +struct ScalarSnapResult { + float value = 0.0f; + float distance = 0.0f; + float snapped_value = 0.0f; + bool active = false; +}; + +enum class TouchEditGestureMode : uint8_t { + kMove = 0, + kResize, +}; + +struct TouchEditSnapOptions { + bool grid_enabled = false; + CGFloat grid_spacing = 0.0f; + CGFloat move_snap_threshold = 0.0f; + CGFloat resize_snap_threshold = 0.0f; + const float* canonical_control_sizes = nullptr; + size_t canonical_control_size_count = 0; +}; + +struct TouchEditSnapResult { + xe::hid::touch::IOSTouchRect frame; + std::vector vertical_guides; + std::vector horizontal_guides; +}; + +CGRect CGRectFromTouchRect(const xe::hid::touch::IOSTouchRect& rect); +bool TouchOverlayIsPortraitForView(UIView* view); +xe::hid::touch::IOSTouchLayoutSpace TouchSafeAreaSpaceForView(UIView* view); +xe::hid::touch::IOSTouchLayoutSpace TouchControlDeckSpaceForView(UIView* view); +xe::hid::touch::IOSTouchLayoutSpace TouchControlSizeSpaceForControlType( + UIView* view, xe::hid::touch::IOSTouchControlType control_type); +xe::hid::touch::IOSTouchRect ClampNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& rect, xe::hid::touch::IOSTouchControlType control_type); +xe::hid::touch::IOSTouchRect ResolveNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& normalized_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, + xe::hid::touch::IOSTouchControlType control_type); +xe::hid::touch::IOSTouchRect ResolveNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& normalized_frame, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, + xe::hid::touch::IOSTouchControlType control_type); +xe::hid::touch::IOSTouchRect NormalizedControlFrameFromResolvedFrame( + const xe::hid::touch::IOSTouchRect& resolved_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, + xe::hid::touch::IOSTouchControlType control_type); +xe::hid::touch::IOSTouchRect NormalizedControlFrameFromResolvedFrame( + const xe::hid::touch::IOSTouchRect& resolved_frame, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, + xe::hid::touch::IOSTouchControlType control_type); +AxisAlignmentSnapResult SoftSnapFrameOrigin(float origin, float extent, + const std::vector& targets, float threshold); +ScalarSnapResult SoftSnapScalar(float value, const std::vector& targets, float threshold); +void AppendUniqueGuidePosition(std::vector& guides, CGFloat guide); +TouchEditSnapResult SnapTouchEditResolvedFrame( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchRect& candidate_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, TouchEditGestureMode gesture_mode, + bool preserve_aspect_ratio, bool preserve_center, const TouchEditSnapOptions& options); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_TOUCH_OVERLAY_GEOMETRY_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.mm b/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.mm new file mode 100644 index 000000000..d144c0a55 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_geometry_ios.mm @@ -0,0 +1,503 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/touch/touch_overlay_geometry_ios.h" + +#include +#include + +namespace xe { +namespace ui { +namespace { + +constexpr CGFloat kIPadTouchDeckMaxLandscapeWidth = 980.0f; +constexpr CGFloat kIPadTouchDeckMaxLandscapeHeight = 560.0f; +constexpr CGFloat kIPadTouchDeckMaxPortraitWidth = 620.0f; +constexpr CGFloat kIPadTouchDeckMaxPortraitHeight = 760.0f; + +float MaxNormalizedControlSizeForType(xe::hid::touch::IOSTouchControlType control_type) { + return control_type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone ? 1.0f : 0.98f; +} + +xe::hid::touch::IOSTouchRect ClampNormalizedControlFrameForSpaces( + const xe::hid::touch::IOSTouchRect& rect, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, + xe::hid::touch::IOSTouchControlType control_type) { + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return {}; + } + + xe::hid::touch::IOSTouchRect result = rect; + const float max_control_size = MaxNormalizedControlSizeForType(control_type); + result.width = std::clamp(result.width, 0.05f, max_control_size); + result.height = std::clamp(result.height, 0.05f, max_control_size); + const float width_in_position_space = result.width * size_space.width / position_space.width; + const float height_in_position_space = result.height * size_space.height / position_space.height; + result.x = std::clamp(result.x, 0.0f, std::max(0.0f, 1.0f - width_in_position_space)); + result.y = std::clamp(result.y, 0.0f, std::max(0.0f, 1.0f - height_in_position_space)); + return result; +} + +float SoftSnapBlend(float distance, float threshold) { + if (threshold <= 0.0f) { + return 0.0f; + } + const float t = std::clamp(1.0f - distance / threshold, 0.0f, 1.0f); + return 0.28f + 0.68f * t * t; +} + +} // namespace + +CGRect CGRectFromTouchRect(const xe::hid::touch::IOSTouchRect& rect) { + return CGRectMake(rect.x, rect.y, rect.width, rect.height); +} + +bool TouchOverlayIsPortraitForView(UIView* view) { + if (!view) { + return false; + } + CGSize size = view.bounds.size; + return size.height >= size.width; +} + +xe::hid::touch::IOSTouchLayoutSpace TouchSafeAreaSpaceForView(UIView* view) { + UIEdgeInsets insets = view.safeAreaInsets; + CGRect bounds = view.bounds; + return xe::hid::touch::IOSTouchLayoutSpace{ + static_cast(insets.left), + static_cast(insets.top), + static_cast(MAX(bounds.size.width - insets.left - insets.right, 0.0)), + static_cast(MAX(bounds.size.height - insets.top - insets.bottom, 0.0)), + }; +} + +xe::hid::touch::IOSTouchLayoutSpace TouchControlDeckSpaceForView(UIView* view) { + xe::hid::touch::IOSTouchLayoutSpace safe_area = TouchSafeAreaSpaceForView(view); + if (safe_area.IsEmpty() || UI_USER_INTERFACE_IDIOM() != UIUserInterfaceIdiomPad) { + return safe_area; + } + + const bool is_portrait = TouchOverlayIsPortraitForView(view); + const CGFloat max_width = + is_portrait ? kIPadTouchDeckMaxPortraitWidth : kIPadTouchDeckMaxLandscapeWidth; + const CGFloat max_height = + is_portrait ? kIPadTouchDeckMaxPortraitHeight : kIPadTouchDeckMaxLandscapeHeight; + const CGFloat deck_width = MIN(static_cast(safe_area.width), max_width); + const CGFloat deck_height = MIN(static_cast(safe_area.height), max_height); + return xe::hid::touch::IOSTouchLayoutSpace{ + static_cast(safe_area.origin_x + (safe_area.width - deck_width) * 0.5f), + static_cast(safe_area.origin_y + safe_area.height - deck_height), + static_cast(deck_width), + static_cast(deck_height), + }; +} + +xe::hid::touch::IOSTouchLayoutSpace TouchControlSizeSpaceForControlType( + UIView* view, xe::hid::touch::IOSTouchControlType control_type) { + switch (control_type) { + case xe::hid::touch::IOSTouchControlType::kMoveStick: + case xe::hid::touch::IOSTouchControlType::kActionButton: + return TouchControlDeckSpaceForView(view); + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + case xe::hid::touch::IOSTouchControlType::kPauseButton: + default: + return TouchSafeAreaSpaceForView(view); + } +} + +xe::hid::touch::IOSTouchRect ClampNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& rect, xe::hid::touch::IOSTouchControlType control_type) { + xe::hid::touch::IOSTouchRect result = rect; + const float max_control_size = MaxNormalizedControlSizeForType(control_type); + result.width = std::clamp(result.width, 0.05f, max_control_size); + result.height = std::clamp(result.height, 0.05f, max_control_size); + result.x = std::clamp(result.x, 0.0f, 1.0f - result.width); + result.y = std::clamp(result.y, 0.0f, 1.0f - result.height); + return result; +} + +xe::hid::touch::IOSTouchRect ResolveNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& normalized_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, + xe::hid::touch::IOSTouchControlType control_type) { + xe::hid::touch::IOSTouchRect clamped_frame = ClampNormalizedControlFrameForSpaces( + normalized_frame, position_space, size_space, control_type); + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return {}; + } + return xe::hid::touch::IOSTouchRect{ + position_space.origin_x + clamped_frame.x * position_space.width, + position_space.origin_y + clamped_frame.y * position_space.height, + clamped_frame.width * size_space.width, + clamped_frame.height * size_space.height, + }; +} + +xe::hid::touch::IOSTouchRect ResolveNormalizedControlFrame( + const xe::hid::touch::IOSTouchRect& normalized_frame, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, + xe::hid::touch::IOSTouchControlType control_type) { + return xe::hid::touch::ResolveIOSTouchRect( + ClampNormalizedControlFrame(normalized_frame, control_type), safe_area); +} + +xe::hid::touch::IOSTouchRect NormalizedControlFrameFromResolvedFrame( + const xe::hid::touch::IOSTouchRect& resolved_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, + xe::hid::touch::IOSTouchControlType control_type) { + if (position_space.IsEmpty() || size_space.IsEmpty()) { + return {}; + } + + return ClampNormalizedControlFrameForSpaces( + xe::hid::touch::IOSTouchRect{ + (resolved_frame.x - position_space.origin_x) / position_space.width, + (resolved_frame.y - position_space.origin_y) / position_space.height, + resolved_frame.width / size_space.width, + resolved_frame.height / size_space.height, + }, + position_space, size_space, control_type); +} + +xe::hid::touch::IOSTouchRect NormalizedControlFrameFromResolvedFrame( + const xe::hid::touch::IOSTouchRect& resolved_frame, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, + xe::hid::touch::IOSTouchControlType control_type) { + if (safe_area.width <= 0.0f || safe_area.height <= 0.0f) { + return {}; + } + + return ClampNormalizedControlFrame( + xe::hid::touch::IOSTouchRect{ + (resolved_frame.x - safe_area.origin_x) / safe_area.width, + (resolved_frame.y - safe_area.origin_y) / safe_area.height, + resolved_frame.width / safe_area.width, + resolved_frame.height / safe_area.height, + }, + control_type); +} + +AxisAlignmentSnapResult SoftSnapFrameOrigin(float origin, float extent, + const std::vector& targets, float threshold) { + AxisAlignmentSnapResult best; + best.origin = origin; + float best_distance = threshold + 1.0f; + float best_snapped_origin = origin; + float best_guide = 0.0f; + + auto consider = [&](float snapped_origin, float guide) { + const float distance = std::abs(snapped_origin - origin); + if (distance < best_distance) { + best_distance = distance; + best_snapped_origin = snapped_origin; + best_guide = guide; + } + }; + + for (float target : targets) { + consider(target, target); + consider(target - extent * 0.5f, target); + consider(target - extent, target); + } + + if (best_distance > threshold) { + return best; + } + + best.active = true; + best.guide = best_guide; + best.snapped_origin = best_snapped_origin; + best.origin = + best_distance <= threshold * 0.20f + ? best_snapped_origin + : origin + (best_snapped_origin - origin) * SoftSnapBlend(best_distance, threshold); + return best; +} + +ScalarSnapResult SoftSnapScalar(float value, const std::vector& targets, float threshold) { + ScalarSnapResult result; + result.value = value; + result.distance = threshold + 1.0f; + float best_target = value; + for (float target : targets) { + const float distance = std::abs(target - value); + if (distance < result.distance) { + result.distance = distance; + best_target = target; + } + } + + if (result.distance > threshold) { + result.distance = 0.0f; + return result; + } + + result.active = true; + result.snapped_value = best_target; + result.value = result.distance <= threshold * 0.20f + ? best_target + : value + (best_target - value) * SoftSnapBlend(result.distance, threshold); + return result; +} + +void AppendUniqueGuidePosition(std::vector& guides, CGFloat guide) { + const bool exists = std::any_of(guides.begin(), guides.end(), [guide](CGFloat existing) { + return std::abs(existing - guide) < 0.5f; + }); + if (!exists) { + guides.push_back(guide); + } +} + +namespace { + +bool TouchEditShouldSkipPeerControl( + const std::vector& controls, size_t candidate_index, + size_t control_index) { + return candidate_index == control_index || + controls[candidate_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone; +} + +std::vector TouchEditVerticalAlignmentTargets( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, const TouchEditSnapOptions& options) { + std::vector targets = {safe_area.origin_x, safe_area.origin_x + safe_area.width * 0.5f, + safe_area.origin_x + safe_area.width}; + if (options.grid_enabled && options.grid_spacing > 0.0f) { + for (float x = safe_area.origin_x + options.grid_spacing * 0.5f; + x < safe_area.origin_x + safe_area.width; x += options.grid_spacing) { + targets.push_back(x); + } + } + + const size_t control_count = std::min(resolved_control_frames.size(), controls.size()); + for (size_t index = 0; index < control_count; ++index) { + if (TouchEditShouldSkipPeerControl(controls, index, control_index)) { + continue; + } + const xe::hid::touch::IOSTouchRect& frame = resolved_control_frames[index]; + targets.push_back(frame.x); + targets.push_back(frame.x + frame.width * 0.5f); + targets.push_back(frame.x + frame.width); + } + return targets; +} + +std::vector TouchEditHorizontalAlignmentTargets( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchLayoutSpace& safe_area, const TouchEditSnapOptions& options) { + std::vector targets = {safe_area.origin_y, safe_area.origin_y + safe_area.height * 0.5f, + safe_area.origin_y + safe_area.height}; + if (options.grid_enabled && options.grid_spacing > 0.0f) { + for (float y = safe_area.origin_y + options.grid_spacing * 0.5f; + y < safe_area.origin_y + safe_area.height; y += options.grid_spacing) { + targets.push_back(y); + } + } + + const size_t control_count = std::min(resolved_control_frames.size(), controls.size()); + for (size_t index = 0; index < control_count; ++index) { + if (TouchEditShouldSkipPeerControl(controls, index, control_index)) { + continue; + } + const xe::hid::touch::IOSTouchRect& frame = resolved_control_frames[index]; + targets.push_back(frame.y); + targets.push_back(frame.y + frame.height * 0.5f); + targets.push_back(frame.y + frame.height); + } + return targets; +} + +std::vector TouchEditWidthSnapTargets( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, const TouchEditSnapOptions& options) { + std::vector targets; + const size_t grid_target_count = + options.grid_spacing > 0.0f ? static_cast(size_space.width / options.grid_spacing) + 1 + : 0; + targets.reserve(options.canonical_control_size_count + resolved_control_frames.size() + + grid_target_count); + if (options.grid_enabled && options.grid_spacing > 0.0f) { + for (float width = options.grid_spacing; width <= size_space.width; + width += options.grid_spacing) { + targets.push_back(width); + } + } + for (size_t index = 0; index < options.canonical_control_size_count; ++index) { + targets.push_back(size_space.width * options.canonical_control_sizes[index]); + } + + if (control_index < controls.size() && + controls[control_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + targets.push_back(size_space.width); + } + const size_t control_count = std::min(resolved_control_frames.size(), controls.size()); + for (size_t index = 0; index < control_count; ++index) { + if (TouchEditShouldSkipPeerControl(controls, index, control_index)) { + continue; + } + targets.push_back(resolved_control_frames[index].width); + } + return targets; +} + +std::vector TouchEditHeightSnapTargets( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, const TouchEditSnapOptions& options) { + std::vector targets; + const size_t grid_target_count = + options.grid_spacing > 0.0f + ? static_cast(size_space.height / options.grid_spacing) + 1 + : 0; + targets.reserve(options.canonical_control_size_count + resolved_control_frames.size() + + grid_target_count); + if (options.grid_enabled && options.grid_spacing > 0.0f) { + for (float height = options.grid_spacing; height <= size_space.height; + height += options.grid_spacing) { + targets.push_back(height); + } + } + for (size_t index = 0; index < options.canonical_control_size_count; ++index) { + targets.push_back(size_space.height * options.canonical_control_sizes[index]); + } + + if (control_index < controls.size() && + controls[control_index].type == xe::hid::touch::IOSTouchControlType::kLookSwipeZone) { + targets.push_back(size_space.height); + } + const size_t control_count = std::min(resolved_control_frames.size(), controls.size()); + for (size_t index = 0; index < control_count; ++index) { + if (TouchEditShouldSkipPeerControl(controls, index, control_index)) { + continue; + } + targets.push_back(resolved_control_frames[index].height); + } + return targets; +} + +} // namespace + +TouchEditSnapResult SnapTouchEditResolvedFrame( + size_t control_index, const std::vector& controls, + const std::vector& resolved_control_frames, + const xe::hid::touch::IOSTouchRect& candidate_frame, + const xe::hid::touch::IOSTouchLayoutSpace& position_space, + const xe::hid::touch::IOSTouchLayoutSpace& size_space, TouchEditGestureMode gesture_mode, + bool preserve_aspect_ratio, bool preserve_center, const TouchEditSnapOptions& options) { + TouchEditSnapResult result; + result.frame = candidate_frame; + if (control_index >= controls.size() || position_space.IsEmpty() || size_space.IsEmpty()) { + return result; + } + + const auto& control = controls[control_index]; + xe::hid::touch::IOSTouchRect frame = + ResolveNormalizedControlFrame(NormalizedControlFrameFromResolvedFrame( + candidate_frame, position_space, size_space, control.type), + position_space, size_space, control.type); + + if (gesture_mode == TouchEditGestureMode::kMove) { + const CGFloat move_threshold = options.move_snap_threshold; + AxisAlignmentSnapResult vertical_result = SoftSnapFrameOrigin( + frame.x, frame.width, + TouchEditVerticalAlignmentTargets(control_index, controls, resolved_control_frames, + position_space, options), + move_threshold); + AxisAlignmentSnapResult horizontal_result = SoftSnapFrameOrigin( + frame.y, frame.height, + TouchEditHorizontalAlignmentTargets(control_index, controls, resolved_control_frames, + position_space, options), + move_threshold); + frame.x = options.grid_enabled && vertical_result.active ? vertical_result.snapped_origin + : vertical_result.origin; + frame.y = options.grid_enabled && horizontal_result.active ? horizontal_result.snapped_origin + : horizontal_result.origin; + if (vertical_result.active) { + AppendUniqueGuidePosition(result.vertical_guides, vertical_result.guide); + } + if (horizontal_result.active) { + AppendUniqueGuidePosition(result.horizontal_guides, horizontal_result.guide); + } + } else { + const CGFloat resize_threshold = options.resize_snap_threshold; + ScalarSnapResult width_result = + SoftSnapScalar(frame.width, + TouchEditWidthSnapTargets(control_index, controls, resolved_control_frames, + size_space, options), + resize_threshold); + ScalarSnapResult height_result = + SoftSnapScalar(frame.height, + TouchEditHeightSnapTargets(control_index, controls, resolved_control_frames, + size_space, options), + resize_threshold); + if (preserve_aspect_ratio) { + const float origin_x = frame.x; + const float origin_y = frame.y; + const float center_x = frame.x + frame.width * 0.5f; + const float center_y = frame.y + frame.height * 0.5f; + const float aspect_ratio = + frame.width > 0.0f ? std::max(frame.height / frame.width, 0.1f) : 1.0f; + const float height_from_width = frame.width * aspect_ratio; + const float width_from_height = frame.height / aspect_ratio; + const bool use_width = width_result.active || (!height_result.active && + std::abs(height_from_width - frame.height) <= + std::abs(width_from_height - frame.width)); + if (use_width) { + frame.width = width_result.active + ? (options.grid_enabled ? width_result.snapped_value : width_result.value) + : frame.width; + frame.height = frame.width * aspect_ratio; + } else { + frame.height = height_result.active ? (options.grid_enabled ? height_result.snapped_value + : height_result.value) + : frame.height; + frame.width = frame.height / aspect_ratio; + } + if (preserve_center) { + frame.x = center_x - frame.width * 0.5f; + frame.y = center_y - frame.height * 0.5f; + } else { + frame.x = origin_x; + frame.y = origin_y; + } + if (width_result.active) { + AppendUniqueGuidePosition(result.vertical_guides, frame.x + frame.width); + } + if (height_result.active) { + AppendUniqueGuidePosition(result.horizontal_guides, frame.y + frame.height); + } + } else { + frame.width = options.grid_enabled && width_result.active ? width_result.snapped_value + : width_result.value; + frame.height = options.grid_enabled && height_result.active ? height_result.snapped_value + : height_result.value; + if (width_result.active) { + AppendUniqueGuidePosition(result.vertical_guides, frame.x + frame.width); + } + if (height_result.active) { + AppendUniqueGuidePosition(result.horizontal_guides, frame.y + frame.height); + } + } + } + + result.frame = ResolveNormalizedControlFrame( + NormalizedControlFrameFromResolvedFrame(frame, position_space, size_space, control.type), + position_space, size_space, control.type); + return result; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/ios/touch/touch_overlay_style_ios.h b/src/xenia/ui/ios/touch/touch_overlay_style_ios.h new file mode 100644 index 000000000..67db481d6 --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_style_ios.h @@ -0,0 +1,39 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_IOS_TOUCH_OVERLAY_STYLE_IOS_H_ +#define XENIA_UI_IOS_TOUCH_OVERLAY_STYLE_IOS_H_ + +#import + +#include + +#include "xenia/hid/touch/touch_layout_ios.h" + +namespace xe { +namespace ui { + +constexpr CGFloat kXeniaTouchComboStickRadiusFraction = 0.32f; + +UIColor* XeniaTouchOverlayAccentColor(xe::hid::touch::IOSTouchTintStyle tint_style, + xe::hid::touch::IOSTouchControlType type); +UIColor* XeniaTouchOverlayBorderColorForControl( + const xe::hid::touch::IOSTouchControlDefinition& control); +UIColor* XeniaTouchOverlayFillColorForControl( + const xe::hid::touch::IOSTouchControlDefinition& control, float opacity); +NSString* XeniaTouchNSStringFromStdString(const std::string& value); +NSString* XeniaTouchConfiguredControlLabelText( + const xe::hid::touch::IOSTouchControlDefinition& control, BOOL fallback_to_identifier); +NSString* XeniaTouchVisibleControlLabelText( + const xe::hid::touch::IOSTouchControlDefinition& control, BOOL fallback_to_identifier); + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_IOS_TOUCH_OVERLAY_STYLE_IOS_H_ diff --git a/src/xenia/ui/ios/touch/touch_overlay_style_ios.mm b/src/xenia/ui/ios/touch/touch_overlay_style_ios.mm new file mode 100644 index 000000000..56ab2386b --- /dev/null +++ b/src/xenia/ui/ios/touch/touch_overlay_style_ios.mm @@ -0,0 +1,107 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/ios/touch/touch_overlay_style_ios.h" + +#include + +#import "xenia/ui/ios/shared/ios_theme.h" + +namespace xe { +namespace ui { + +UIColor* XeniaTouchOverlayAccentColor( + xe::hid::touch::IOSTouchTintStyle tint_style, + xe::hid::touch::IOSTouchControlType type) { + switch (tint_style) { + case xe::hid::touch::IOSTouchTintStyle::kAmber: + return [XeniaTheme touchTintAmber]; + case xe::hid::touch::IOSTouchTintStyle::kSky: + return [XeniaTheme touchTintSky]; + case xe::hid::touch::IOSTouchTintStyle::kMint: + return [XeniaTheme touchTintMint]; + case xe::hid::touch::IOSTouchTintStyle::kRose: + return [XeniaTheme touchTintRose]; + case xe::hid::touch::IOSTouchTintStyle::kLime: + return [XeniaTheme touchTintLime]; + case xe::hid::touch::IOSTouchTintStyle::kCoral: + return [XeniaTheme touchTintCoral]; + case xe::hid::touch::IOSTouchTintStyle::kSlate: + return [XeniaTheme touchTintSlate]; + case xe::hid::touch::IOSTouchTintStyle::kAuto: + default: + if (type == xe::hid::touch::IOSTouchControlType::kPauseButton) { + return [XeniaTheme touchTintAmber]; + } + return [UIColor colorWithWhite:1.0 alpha:1.0]; + } +} + +UIColor* XeniaTouchOverlayBorderColorForControl( + const xe::hid::touch::IOSTouchControlDefinition& control) { + UIColor* accent_color = + XeniaTouchOverlayAccentColor(control.tint_style, control.type); + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kPauseButton: + return [accent_color colorWithAlphaComponent:0.95]; + case xe::hid::touch::IOSTouchControlType::kMoveStick: + return [accent_color colorWithAlphaComponent:0.38]; + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + return [accent_color colorWithAlphaComponent:0.12]; + case xe::hid::touch::IOSTouchControlType::kActionButton: + default: + return [accent_color colorWithAlphaComponent:0.32]; + } +} + +UIColor* XeniaTouchOverlayFillColorForControl( + const xe::hid::touch::IOSTouchControlDefinition& control, float opacity) { + const CGFloat alpha = + static_cast(std::clamp(opacity, 0.0f, 1.0f)); + UIColor* accent_color = + XeniaTouchOverlayAccentColor(control.tint_style, control.type); + switch (control.type) { + case xe::hid::touch::IOSTouchControlType::kPauseButton: + return [accent_color colorWithAlphaComponent:0.18f * alpha]; + case xe::hid::touch::IOSTouchControlType::kMoveStick: + return [accent_color colorWithAlphaComponent:0.10f * alpha]; + case xe::hid::touch::IOSTouchControlType::kLookSwipeZone: + return [UIColor clearColor]; + case xe::hid::touch::IOSTouchControlType::kActionButton: + default: + return [accent_color colorWithAlphaComponent:0.08f * alpha]; + } +} + +NSString* XeniaTouchNSStringFromStdString(const std::string& value) { + return value.empty() ? @"" : [NSString stringWithUTF8String:value.c_str()]; +} + +NSString* XeniaTouchConfiguredControlLabelText( + const xe::hid::touch::IOSTouchControlDefinition& control, + BOOL fallback_to_identifier) { + std::string label = xe::hid::touch::IOSTouchConfiguredControlLabel(control); + if (label.empty() && fallback_to_identifier) { + label = control.identifier; + } + return XeniaTouchNSStringFromStdString(label); +} + +NSString* XeniaTouchVisibleControlLabelText( + const xe::hid::touch::IOSTouchControlDefinition& control, + BOOL fallback_to_identifier) { + std::string label = xe::hid::touch::IOSTouchVisibleControlLabel(control); + if (label.empty() && fallback_to_identifier) { + label = control.identifier; + } + return XeniaTouchNSStringFromStdString(label); +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/metal/CMakeLists.txt b/src/xenia/ui/metal/CMakeLists.txt index 040d8c5e6..071ae368a 100644 --- a/src/xenia/ui/metal/CMakeLists.txt +++ b/src/xenia/ui/metal/CMakeLists.txt @@ -16,10 +16,18 @@ target_link_libraries(xenia-ui-metal PUBLIC "-framework Foundation" "-framework Metal" "-framework MetalKit" - "-framework MetalFX" "-framework QuartzCore" - "-framework AppKit" ) +if(XE_PLATFORM_IOS) + target_link_libraries(xenia-ui-metal PUBLIC + "-framework UIKit" + ) +elseif(XE_PLATFORM_MACOS) + target_link_libraries(xenia-ui-metal PUBLIC + "-framework AppKit" + "-framework MetalFX" + ) +endif() xe_shader_rules_metal(xenia-ui-metal ${CMAKE_CURRENT_SOURCE_DIR}/../shaders) if(TARGET xenia-gpu-metal-metal-shaders) add_dependencies(xenia-ui-metal xenia-gpu-metal-metal-shaders) diff --git a/src/xenia/ui/metal/metal_presenter.mm b/src/xenia/ui/metal/metal_presenter.mm index b9203c2a0..29ee32a6a 100644 --- a/src/xenia/ui/metal/metal_presenter.mm +++ b/src/xenia/ui/metal/metal_presenter.mm @@ -19,20 +19,27 @@ #include "xenia/base/autorelease_pool_mac.h" #include "xenia/base/cvar.h" #include "xenia/base/logging.h" +#include "xenia/base/platform.h" #include "xenia/gpu/shaders/bytecode/metal/apply_gamma_pwl_cs.h" #include "xenia/gpu/shaders/bytecode/metal/apply_gamma_table_cs.h" #include "xenia/ui/metal/metal_provider.h" #include "xenia/ui/shaders/bytecode/metal/guest_output_bilinear_dither_ps.h" #include "xenia/ui/shaders/bytecode/metal/guest_output_bilinear_ps.h" #include "xenia/ui/shaders/bytecode/metal/guest_output_triangle_strip_rect_vs.h" -#include "xenia/ui/surface_mac.h" +#if XE_PLATFORM_IOS +#import +#include "xenia/ui/ios/game/surface_ios.h" +#else #import +#include "xenia/ui/surface_mac.h" +#endif + #import #import #import #import -#if __has_include() +#if !XE_PLATFORM_IOS && __has_include() #import #define XE_METALFX_AVAILABLE 1 #else @@ -271,7 +278,11 @@ MTL::Texture* MetalPresenter::GetCachedPresenterTextureView( } Surface::TypeFlags MetalPresenter::GetSupportedSurfaceTypes() const { +#if XE_PLATFORM_IOS + return Surface::kTypeFlag_iOSUIView; +#else return Surface::kTypeFlag_MacNSView; +#endif } bool MetalPresenter::CaptureGuestOutput(RawImage& image_out) { @@ -749,6 +760,17 @@ MetalPresenter::ConnectOrReconnectPaintingToSurfaceFromUIThread(Surface& new_sur CAMetalLayer* metal_layer = nullptr; CGFloat surface_scale = 1.0; +#if XE_PLATFORM_IOS + if (surface_type != Surface::kTypeIndex_iOSUIView) { + XELOGE("Metal presenter requires iOSUIView surface, got: {}", static_cast(surface_type)); + return SurfacePaintConnectResult::kFailure; + } + iOSUIViewSurface& ios_surface = static_cast(new_surface); + metal_layer = ios_surface.GetOrCreateMetalLayer(); + // iOS intentionally presents to a fixed 720p-class drawable and lets the + // UIView frame handle screen placement, Fit/Stretch/Zoom, and device scaling. + surface_scale = 1.0; +#else if (surface_type != Surface::kTypeIndex_MacNSView) { XELOGE("Metal presenter requires MacNSView surface, got: {}", static_cast(surface_type)); return SurfacePaintConnectResult::kFailure; @@ -767,6 +789,7 @@ MetalPresenter::ConnectOrReconnectPaintingToSurfaceFromUIThread(Surface& new_sur surface_scale = view.window.backingScaleFactor; } } +#endif if (!metal_layer) { XELOGE("Metal presenter failed to get CAMetalLayer from surface"); @@ -778,9 +801,11 @@ MetalPresenter::ConnectOrReconnectPaintingToSurfaceFromUIThread(Surface& new_sur if (surface_scale <= 0.0) { surface_scale = 1.0; } +#if !XE_PLATFORM_IOS if (!cvars::metal_presenter_use_backing_scale) { surface_scale = 1.0; } +#endif surface_scale_ = static_cast(surface_scale); surface_width_in_points_ = new_surface_width; surface_height_in_points_ = new_surface_height; @@ -790,9 +815,13 @@ MetalPresenter::ConnectOrReconnectPaintingToSurfaceFromUIThread(Surface& new_sur metal_layer.minificationFilter = kCAFilterNearest; metal_layer.magnificationFilter = kCAFilterNearest; +#if XE_PLATFORM_IOS + is_vsync_implicit_out = true; +#else const bool tearing_allowed = cvars::metal_allow_tearing; metal_layer.displaySyncEnabled = tearing_allowed ? NO : YES; is_vsync_implicit_out = !tearing_allowed; +#endif metal_layer_ = metal_layer; diff --git a/src/xenia/ui/surface.h b/src/xenia/ui/surface.h index 3d64036e9..46a4648f9 100644 --- a/src/xenia/ui/surface.h +++ b/src/xenia/ui/surface.h @@ -34,6 +34,8 @@ class Surface { kTypeIndex_XcbWindow, // macOS. kTypeIndex_MacNSView, + // iOS. + kTypeIndex_iOSUIView, // Windows. kTypeIndex_Win32Hwnd, }; @@ -44,6 +46,7 @@ class Surface { kTypeFlag_WaylandWindow = TypeFlags(1) << kTypeIndex_WaylandWindow, kTypeFlag_XcbWindow = TypeFlags(1) << kTypeIndex_XcbWindow, kTypeFlag_MacNSView = TypeFlags(1) << kTypeIndex_MacNSView, + kTypeFlag_iOSUIView = TypeFlags(1) << kTypeIndex_iOSUIView, kTypeFlag_Win32Hwnd = TypeFlags(1) << kTypeIndex_Win32Hwnd, }; diff --git a/src/xenia/ui/vulkan/vulkan_instance.cc b/src/xenia/ui/vulkan/vulkan_instance.cc index 964439185..14b73e4e7 100644 --- a/src/xenia/ui/vulkan/vulkan_instance.cc +++ b/src/xenia/ui/vulkan/vulkan_instance.cc @@ -21,11 +21,13 @@ #include "xenia/base/platform.h" #include "xenia/ui/vulkan/vulkan_presenter.h" -#if XE_PLATFORM_LINUX +#if XE_PLATFORM_LINUX || XE_PLATFORM_APPLE #include #elif XE_PLATFORM_WIN32 #include "xenia/base/platform_win.h" -#elif XE_PLATFORM_MAC +#endif + +#if XE_PLATFORM_MAC // MoltenVK is statically linked; declare the two entry points we resolve at // load time (vulkan_api.h sets VK_NO_PROTOTYPES, so the prototypes from // vulkan.h aren't visible). @@ -615,7 +617,7 @@ VulkanInstance::~VulkanInstance() { functions_.vkDestroyInstance(instance_, nullptr); } -#if XE_PLATFORM_LINUX +#if XE_PLATFORM_LINUX || XE_PLATFORM_APPLE if (loader_) { dlclose(loader_); } diff --git a/src/xenia/ui/vulkan/vulkan_instance.h b/src/xenia/ui/vulkan/vulkan_instance.h index f64a70b60..8fe88b87b 100644 --- a/src/xenia/ui/vulkan/vulkan_instance.h +++ b/src/xenia/ui/vulkan/vulkan_instance.h @@ -130,7 +130,7 @@ class VulkanInstance { std::unique_ptr renderdoc_api_; -#if XE_PLATFORM_LINUX +#if XE_PLATFORM_LINUX || XE_PLATFORM_APPLE void* loader_ = nullptr; #elif XE_PLATFORM_WIN32 HMODULE loader_ = nullptr; diff --git a/src/xenia/ui/vulkan/vulkan_presenter.cc b/src/xenia/ui/vulkan/vulkan_presenter.cc index 0960e3e3a..4c57b04b3 100644 --- a/src/xenia/ui/vulkan/vulkan_presenter.cc +++ b/src/xenia/ui/vulkan/vulkan_presenter.cc @@ -2601,6 +2601,11 @@ VkPipeline VulkanPresenter::CreateGuestOutputPaintPipeline( input_assembly_state.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; input_assembly_state.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP; + // MoltenVK uses Metal primitive restart behavior for strip topologies, where + // disabling restart isn't supported. The presenter draws non-indexed strips, + // so enabling it here only avoids the unsupported-state warning. + input_assembly_state.primitiveRestartEnable = + vulkan_device_->properties().driverID == VK_DRIVER_ID_MOLTENVK; VkPipelineViewportStateCreateInfo viewport_state = {}; viewport_state.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; diff --git a/src/xenia/vfs/devices/stfs_xbox.h b/src/xenia/vfs/devices/stfs_xbox.h index 43a1d7456..d3db493fe 100644 --- a/src/xenia/vfs/devices/stfs_xbox.h +++ b/src/xenia/vfs/devices/stfs_xbox.h @@ -10,6 +10,7 @@ #ifndef XENIA_VFS_DEVICES_STFS_XBOX_H_ #define XENIA_VFS_DEVICES_STFS_XBOX_H_ +#include #include #include "xenia/base/logging.h" @@ -369,66 +370,143 @@ struct XContentMetadata { char16_t chars[kNumLanguagesV2 - kNumLanguagesV1][128]; } description_ex_raw; - // Resolves a system language to an STFS metadata slot index. STFS headers - // only hold kNumLanguagesV2 languages; the extended languages and kInvalid - // have no slot and resolve to English. Warns only for values outside the - // enum, which signal a bad language id rather than a known format limit. - static uint32_t language_slot(XLanguage language) { - uint32_t lang_id = uint32_t(language) - 1; - if (lang_id >= kNumLanguagesV2) { - if (uint32_t(language) >= uint32_t(XLanguage::kMaxLanguages)) { - XELOGW("STFS metadata: invalid language {}, using English", - uint32_t(language)); + static uint16_t load_be16_unaligned(const void* data) { + const auto* bytes = reinterpret_cast(data); + return uint16_t((uint16_t(bytes[0]) << 8) | uint16_t(bytes[1])); + } + + static uint32_t load_be32_unaligned(const void* data) { + const auto* bytes = reinterpret_cast(data); + return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) | + (uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]); + } + + static uint64_t load_be64_unaligned(const void* data) { + const auto* bytes = reinterpret_cast(data); + return (uint64_t(bytes[0]) << 56) | (uint64_t(bytes[1]) << 48) | + (uint64_t(bytes[2]) << 40) | (uint64_t(bytes[3]) << 32) | + (uint64_t(bytes[4]) << 24) | (uint64_t(bytes[5]) << 16) | + (uint64_t(bytes[6]) << 8) | uint64_t(bytes[7]); + } + + const uint8_t* field_bytes(size_t offset) const { + return reinterpret_cast(this) + offset; + } + + uint32_t content_type_value() const { + return load_be32_unaligned( + field_bytes(offsetof(XContentMetadata, content_type))); + } + + uint32_t metadata_version_value() const { + return load_be32_unaligned( + field_bytes(offsetof(XContentMetadata, metadata_version))); + } + + uint64_t content_size_value() const { + return load_be64_unaligned( + field_bytes(offsetof(XContentMetadata, content_size))); + } + + uint64_t profile_id_value() const { + return load_be64_unaligned( + field_bytes(offsetof(XContentMetadata, profile_id))); + } + + uint32_t data_file_count_value() const { + return load_be32_unaligned( + field_bytes(offsetof(XContentMetadata, data_file_count))); + } + + uint64_t data_file_size_value() const { + return load_be64_unaligned( + field_bytes(offsetof(XContentMetadata, data_file_size))); + } + + XContentVolumeType volume_type_value() const { + return static_cast(load_be32_unaligned( + field_bytes(offsetof(XContentMetadata, volume_type)))); + } + + uint32_t title_thumbnail_size_value() const { + return load_be32_unaligned( + field_bytes(offsetof(XContentMetadata, title_thumbnail_size))); + } + + static std::u16string load_u16_string_unaligned(const uint8_t* data, + size_t max_chars) { + std::u16string value; + value.reserve(max_chars); + for (size_t i = 0; i < max_chars; ++i) { + const uint16_t code_unit = + load_be16_unaligned(data + i * sizeof(uint16_t)); + if (!code_unit) { + break; } + value.push_back(static_cast(code_unit)); + } + return value; + } + + std::u16string load_localized_string(size_t primary_offset, + size_t extended_offset, + XLanguage language) const { + uint32_t lang_id = language == XLanguage::kInvalid + ? uint32_t(XLanguage::kEnglish) - 1 + : uint32_t(language) - 1; + + if (lang_id >= kNumLanguagesV2) { + assert_always(); lang_id = uint32_t(XLanguage::kEnglish) - 1; } - return lang_id; + + const size_t kSlotChars = 128; + const size_t kSlotBytes = kSlotChars * sizeof(uint16_t); + if (lang_id < kNumLanguagesV1) { + return load_u16_string_unaligned( + field_bytes(primary_offset) + lang_id * kSlotBytes, kSlotChars); + } + if (metadata_version_value() >= 2) { + return load_u16_string_unaligned( + field_bytes(extended_offset) + + (lang_id - kNumLanguagesV1) * kSlotBytes, + kSlotChars); + } + + assert_always(); + return u""; } std::u16string display_name(XLanguage language) const { - uint32_t lang_id = language_slot(language); - - const be* str = 0; - if (lang_id < kNumLanguagesV1) { - str = display_name_raw.uint[lang_id]; - } else if (lang_id < kNumLanguagesV2 && metadata_version >= 2) { - str = display_name_ex_raw.uint[lang_id - kNumLanguagesV1]; - } - - if (!str) { - return u""; - } - - return load_and_swap(str); + return load_localized_string( + offsetof(XContentMetadata, display_name_raw), + offsetof(XContentMetadata, display_name_ex_raw), language); } std::u16string description(XLanguage language) const { - uint32_t lang_id = language_slot(language); - - const be* str = 0; - if (lang_id < kNumLanguagesV1) { - str = description_raw.uint[lang_id]; - } else if (lang_id < kNumLanguagesV2 && metadata_version >= 2) { - str = description_ex_raw.uint[lang_id - kNumLanguagesV1]; - } - - if (!str) { - return u""; - } - - return load_and_swap(str); + return load_localized_string(offsetof(XContentMetadata, description_raw), + offsetof(XContentMetadata, description_ex_raw), + language); } std::u16string publisher() const { - return load_and_swap(publisher_raw.uint); + return load_u16_string_unaligned( + field_bytes(offsetof(XContentMetadata, publisher_raw)), 64); } std::u16string title_name() const { - return load_and_swap(title_name_raw.uint); + return load_u16_string_unaligned( + field_bytes(offsetof(XContentMetadata, title_name_raw)), 64); } bool set_display_name(XLanguage language, const std::u16string_view value) { - uint32_t lang_id = language_slot(language); + uint32_t lang_id = uint32_t(language) - 1; + + if (lang_id >= kNumLanguagesV2) { + assert_always(); + // no room for this lang, store in english slot.. + lang_id = uint32_t(XLanguage::kEnglish) - 1; + } char16_t* str = 0; if (lang_id < kNumLanguagesV1) { @@ -438,6 +516,8 @@ struct XContentMetadata { } if (!str) { + // Invalid language ID? + assert_always(); return false; } @@ -447,7 +527,13 @@ struct XContentMetadata { } bool set_description(XLanguage language, const std::u16string_view value) { - uint32_t lang_id = language_slot(language); + uint32_t lang_id = uint32_t(language) - 1; + + if (lang_id >= kNumLanguagesV2) { + assert_always(); + // no room for this lang, store in english slot.. + lang_id = uint32_t(XLanguage::kEnglish) - 1; + } char16_t* str = 0; if (lang_id < kNumLanguagesV1) { @@ -457,6 +543,8 @@ struct XContentMetadata { } if (!str) { + // Invalid language ID? + assert_always(); return false; } @@ -502,7 +590,8 @@ struct XContentContainerHeader { // to affect header.header_size bool is_package_readonly() const { - if (content_metadata.volume_type == vfs::XContentVolumeType::kSvod) { + if (content_metadata.volume_type_value() == + vfs::XContentVolumeType::kSvod) { return true; } diff --git a/src/xenia/vfs/devices/xcontent_container_device.cc b/src/xenia/vfs/devices/xcontent_container_device.cc index 366201cca..0e947688c 100644 --- a/src/xenia/vfs/devices/xcontent_container_device.cc +++ b/src/xenia/vfs/devices/xcontent_container_device.cc @@ -49,7 +49,7 @@ XContentContainerDevice::CreateContentDevice( return nullptr; } - switch (header->content_metadata.volume_type) { + switch (header->content_metadata.volume_type_value()) { case XContentVolumeType::kStfs: return std::make_unique(mount_path, host_path); break; @@ -162,7 +162,8 @@ kernel::xam::XCONTENT_AGGREGATE_DATA XContentContainerDevice::content_header() data.device_id = 1; data.title_id = header_->content_metadata.execution_info.title_id; - data.content_type = header_->content_metadata.content_type; + data.content_type = + static_cast(header_->content_metadata.content_type_value()); auto name = header_->content_metadata.display_name(XLanguage::kEnglish); if (name.empty()) { diff --git a/src/xenia/vfs/devices/xcontent_container_device.h b/src/xenia/vfs/devices/xcontent_container_device.h index 4c3d3a573..8c0111267 100644 --- a/src/xenia/vfs/devices/xcontent_container_device.h +++ b/src/xenia/vfs/devices/xcontent_container_device.h @@ -57,14 +57,14 @@ class XContentContainerDevice : public Device { return files_total_size_ - sizeof(XContentContainerHeader); } - uint64_t xuid() const { return header_->content_metadata.profile_id; } + uint64_t xuid() const { return header_->content_metadata.profile_id_value(); } uint32_t title_id() const { return header_->content_metadata.execution_info.title_id; } uint32_t content_type() const { - return static_cast(header_->content_metadata.content_type.get()); + return header_->content_metadata.content_type_value(); } kernel::xam::XCONTENT_AGGREGATE_DATA content_header() const; diff --git a/src/xenia/vfs/devices/xcontent_devices/stfs_container_device.cc b/src/xenia/vfs/devices/xcontent_devices/stfs_container_device.cc index af35cefe6..52cfcca65 100644 --- a/src/xenia/vfs/devices/xcontent_devices/stfs_container_device.cc +++ b/src/xenia/vfs/devices/xcontent_devices/stfs_container_device.cc @@ -38,7 +38,7 @@ void StfsContainerDevice::SetupContainer() { XContentContainerDevice::Result StfsContainerDevice::LoadHostFiles() { const XContentContainerHeader* header = GetContainerHeader(); - if (header->content_metadata.data_file_count > 0) { + if (header->content_metadata.data_file_count_value() > 0) { XELOGW("STFS container is not a single file. Loading might fail!"); } diff --git a/src/xenia/vfs/devices/xcontent_devices/svod_container_device.cc b/src/xenia/vfs/devices/xcontent_devices/svod_container_device.cc index 9830d3d78..8b90b20b9 100644 --- a/src/xenia/vfs/devices/xcontent_devices/svod_container_device.cc +++ b/src/xenia/vfs/devices/xcontent_devices/svod_container_device.cc @@ -46,10 +46,11 @@ SvodContainerDevice::Result SvodContainerDevice::LoadHostFiles() { return left.name < right.name; }); - if (fragment_files.size() != header_->content_metadata.data_file_count) { + const uint32_t data_file_count = + header_->content_metadata.data_file_count_value(); + if (fragment_files.size() != data_file_count) { XELOGE("SVOD expecting {} data fragments, but {} are present.", - header_->content_metadata.data_file_count.get(), - fragment_files.size()); + data_file_count, fragment_files.size()); return Result::kFileMismatch; } @@ -336,7 +337,7 @@ XContentContainerDevice::Result SvodContainerDevice::SetNormalLayout( uint8_t magic_buf[20]; const uint32_t magic_pos = - header_->content_metadata.data_file_count == 1 ? 0xD000 : 0x2000; + header_->content_metadata.data_file_count_value() == 1 ? 0xD000 : 0x2000; xe::filesystem::Seek(header, magic_pos, SEEK_SET); if (fread(magic_buf, 1, countof(magic_buf), header) != countof(magic_buf)) { XELOGE("ReadSVOD failed to read SVOD magic at 0x{:04X}", magic_pos); @@ -352,7 +353,7 @@ XContentContainerDevice::Result SvodContainerDevice::SetNormalLayout( // is a single-file system. The STFS Header is 0xB000 bytes and the // remaining 0x2000 is from hash tables. In most cases, these will be // STFS, not SVOD. - if (header_->content_metadata.data_file_count == 1) { + if (header_->content_metadata.data_file_count_value() == 1) { svod_base_offset_ = 0xB000; svod_layout_ = SvodLayoutType::kSingleFile; XELOGI("SVOD is a single file. Magic block present at 0xD000."); diff --git a/src/xenia/vfs/stfs_metadata.cc b/src/xenia/vfs/stfs_metadata.cc index 58efe1b2d..942872eac 100644 --- a/src/xenia/vfs/stfs_metadata.cc +++ b/src/xenia/vfs/stfs_metadata.cc @@ -81,9 +81,9 @@ std::optional ExtractStfsMetadata( metadata.disc_number = exec_info.disc_number; metadata.disc_count = exec_info.disc_count; - metadata.content_type = static_cast( - static_cast(content_meta.content_type)); - metadata.content_size = content_meta.content_size; + metadata.content_type = content_meta.content_type_value(); + metadata.content_size = content_meta.content_size_value(); + metadata.data_file_count = content_meta.data_file_count_value(); // Tiered: requested language → English → first populated slot. Publishers // routinely misuse slots (e.g. Japanese text in the English slot for @@ -116,7 +116,7 @@ std::optional ExtractStfsMetadata( metadata.publisher = U16ToUtf8(content_meta.publisher()); metadata.title_name = U16ToUtf8(content_meta.title_name()); - const uint32_t thumb_size = content_meta.title_thumbnail_size; + const uint32_t thumb_size = content_meta.title_thumbnail_size_value(); if (thumb_size > 0 && thumb_size <= XContentMetadata::kThumbLengthV1) { metadata.icon_data.assign(content_meta.title_thumbnail, content_meta.title_thumbnail + thumb_size); diff --git a/src/xenia/vfs/stfs_metadata.h b/src/xenia/vfs/stfs_metadata.h index a666f0111..f5689b05b 100644 --- a/src/xenia/vfs/stfs_metadata.h +++ b/src/xenia/vfs/stfs_metadata.h @@ -28,6 +28,7 @@ struct StfsMetadata { uint32_t savegame_id = 0; uint32_t content_type = 0; uint64_t content_size = 0; + uint32_t data_file_count = 0; std::string display_name; std::string description; diff --git a/third_party/CMakeLists.txt b/third_party/CMakeLists.txt index cd71bf515..6efccdd95 100644 --- a/third_party/CMakeLists.txt +++ b/third_party/CMakeLists.txt @@ -142,7 +142,11 @@ if(WIN32) discord-rpc/src/connection_win.cpp discord-rpc/src/discord_register_win.cpp ) -elseif(APPLE) +elseif(XE_PLATFORM_IOS) + target_sources(discord-rpc PRIVATE + discord-rpc/src/connection_unix.cpp + ) +elseif(XE_PLATFORM_MACOS) target_sources(discord-rpc PRIVATE discord-rpc/src/connection_unix.cpp discord-rpc/src/discord_register_osx.m @@ -164,6 +168,28 @@ add_library(fmt STATIC ) target_include_directories(fmt PUBLIC fmt/include) +# ============================================================================== +# MoltenVK (iOS Vulkan backend) +# ============================================================================== +if(XE_PLATFORM_IOS AND XENIA_ENABLE_IOS_MOLTENVK) + set(_xe_moltenvk_ios_framework + "${CMAKE_CURRENT_SOURCE_DIR}/MoltenVK/ios/MoltenVK.framework") + set(_xe_moltenvk_ios_binary "${_xe_moltenvk_ios_framework}/MoltenVK") + if(NOT EXISTS "${_xe_moltenvk_ios_binary}") + message(FATAL_ERROR + "MoltenVK iOS framework not found at ${_xe_moltenvk_ios_binary}. " + "Run tools/fetch_moltenvk.py --platform ios before configuring.") + endif() + add_library(xenia-third-party-moltenvk SHARED IMPORTED GLOBAL) + set_target_properties(xenia-third-party-moltenvk PROPERTIES + FRAMEWORK TRUE + IMPORTED_CONFIGURATIONS "Release" + IMPORTED_LOCATION "${_xe_moltenvk_ios_binary}" + IMPORTED_LOCATION_RELEASE "${_xe_moltenvk_ios_binary}" + INTERFACE_INCLUDE_DIRECTORIES + "${CMAKE_CURRENT_SOURCE_DIR}/MoltenVK/include") +endif() + # ============================================================================== # glslang-spirv (runtime: SPIR-V emission primitives for Xenia's own # SpirvShaderTranslator; used by the Vulkan backend and by the Metal backend's @@ -182,14 +208,15 @@ add_library(glslang-spirv STATIC ) target_include_directories(glslang-spirv PUBLIC glslang) -# Cross-compiling x64-host → ARM64-target on Windows: the cross -# toolchain produces an ARM64 PE for xenia-shader-cc that won't run on -# the x64 host, so reuse the binary from a previous native build instead -# (Windows-on-ARM runs x64 fine via Prism, and macOS handles its own -# x86_64↔arm64 via Rosetta — both directions are unaffected). +# Some target builds can't execute their own xenia-shader-cc. Reuse the +# binary from a previous native build instead. set(_xe_use_host_shader_cc FALSE) -if(WIN32 AND CMAKE_CROSSCOMPILING AND XE_TARGET_AARCH64 +if(XE_PLATFORM_IOS) + set(_xe_use_host_shader_cc TRUE) +elseif(WIN32 AND CMAKE_CROSSCOMPILING AND XE_TARGET_AARCH64 AND CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "AMD64|x86_64") + # Cross-compiling x64-host -> ARM64-target on Windows produces an ARM64 PE + # that won't run on the x64 host. set(_xe_use_host_shader_cc TRUE) endif() @@ -284,7 +311,7 @@ endif() # NOT _xe_use_host_shader_cc (SPIRV-Tools + glslang block) # SPIRV-Cross (Apple only — used at runtime by xenia-gpu-metal for SPIR-V → MSL # via MslShader, and by MoltenVKShaderConverter below) # ============================================================================== -if(APPLE) +if(XE_PLATFORM_MACOS) add_library(spirv-cross STATIC SPIRV-Cross/spirv_cross.cpp SPIRV-Cross/spirv_cross_parsed_ir.cpp @@ -580,31 +607,47 @@ if(MSVC) target_compile_options(xenia-shader-cc PRIVATE /EHsc) endif() -else() # _xe_use_host_shader_cc: x64→ARM64 cross-compile, use native build's binary +else() # _xe_use_host_shader_cc: use native build's binary -# Native x64 build lives at /build/ (xenia-build.py: get_build_dir(None)). +# Native host build lives at /build/ (xenia-build.py: get_build_dir(None)). set(_xe_host_shader_cc_dir "${CMAKE_SOURCE_DIR}/build/host_tools") +set(_xe_host_shader_cc_name "xenia-shader-cc") +if(WIN32) + set(_xe_host_shader_cc_name "xenia-shader-cc.exe") +endif() set(_xe_host_shader_cc_any "") foreach(_cfg Release Checked Debug) - if(EXISTS "${_xe_host_shader_cc_dir}/${_cfg}/xenia-shader-cc.exe") - set(_xe_host_shader_cc_any "${_xe_host_shader_cc_dir}/${_cfg}/xenia-shader-cc.exe") + if(EXISTS "${_xe_host_shader_cc_dir}/${_cfg}/${_xe_host_shader_cc_name}") + set(_xe_host_shader_cc_any + "${_xe_host_shader_cc_dir}/${_cfg}/${_xe_host_shader_cc_name}") break() endif() endforeach() if(NOT _xe_host_shader_cc_any) + if(XE_PLATFORM_IOS) + set(_xe_host_shader_cc_build_hint + "./xb build --target=xenia-shader-cc --config=Release") + set(_xe_host_shader_cc_target_desc + "iOS device builds need a native macOS host xenia-shader-cc") + else() + set(_xe_host_shader_cc_build_hint + "xb build --target=xenia-shader-cc --config=Release") + set(_xe_host_shader_cc_target_desc + "Cross-compile to ARM64 needs a host xenia-shader-cc.exe") + endif() message(FATAL_ERROR - "Cross-compile to ARM64 needs a host xenia-shader-cc.exe but none was found " + "${_xe_host_shader_cc_target_desc} but none was found " "under ${_xe_host_shader_cc_dir}//.\n" " Run a native build first to produce it, e.g.:\n" - " xb build --target xenia-shader-cc --config=release\n" - " Then re-run the cross-compile.") + " ${_xe_host_shader_cc_build_hint}\n" + " Then re-run the target build.") endif() add_executable(xenia-shader-cc IMPORTED GLOBAL) set_target_properties(xenia-shader-cc PROPERTIES - IMPORTED_LOCATION "${_xe_host_shader_cc_any}" - IMPORTED_LOCATION_RELEASE "${_xe_host_shader_cc_dir}/Release/xenia-shader-cc.exe" - IMPORTED_LOCATION_CHECKED "${_xe_host_shader_cc_dir}/Checked/xenia-shader-cc.exe" - IMPORTED_LOCATION_DEBUG "${_xe_host_shader_cc_dir}/Debug/xenia-shader-cc.exe") + IMPORTED_CONFIGURATIONS "Debug;Release;Checked" + IMPORTED_LOCATION_DEBUG "${_xe_host_shader_cc_any}" + IMPORTED_LOCATION_RELEASE "${_xe_host_shader_cc_any}" + IMPORTED_LOCATION_CHECKED "${_xe_host_shader_cc_any}") endif() @@ -1280,7 +1323,7 @@ ffmpeg_common(libavformat) # ============================================================================== # wxWidgets # ============================================================================== -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android") +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android" AND NOT XE_PLATFORM_IOS) # Build from vendored source — matches what we ship on Windows/macOS, and # on Linux gives us a known-good 3.3 (system 3.2 has GTK3/Wayland issues). # Strip the directory-level /W0 (or -w) so wxWidgets's own /W3 doesn't diff --git a/tools/build/shader_cc.cc b/tools/build/shader_cc.cc index 66ac7bca8..9c4f68d40 100644 --- a/tools/build/shader_cc.cc +++ b/tools/build/shader_cc.cc @@ -12,8 +12,9 @@ // SHADING_LANGUAGE_GLSL_XE=1 and includes resolved relative to the // input's directory. // -// --msl (Apple only): .xesl -> .metallib via `xcrun metal -x metal -D -// SHADING_LANGUAGE_MSL_XE=1`, emitted as `const uint8_t _metallib[]`. +// --msl (Apple only): .xesl -> .metallib via `xcrun -sdk metal -x +// metal -D SHADING_LANGUAGE_MSL_XE=1`, emitted as +// `const uint8_t _metallib[]`. // // --dxbc: HLSL/XeSL -> DXBC via fxc.exe with /Fh writing the header // directly (SHADING_LANGUAGE_HLSL_XE=1). FXC_PATH env overrides the @@ -546,7 +547,7 @@ int main(int argc, char** argv) { std::vector metal_cmd = { "xcrun", "-sdk", - "macosx", + metal_sdk, "metal", "-x", "metal", @@ -589,7 +590,7 @@ int main(int argc, char** argv) { cleanup(); return 1; } - if (RunCommand({"xcrun", "-sdk", "macosx", "metallib", air_path.string(), + if (RunCommand({"xcrun", "-sdk", metal_sdk, "metallib", air_path.string(), "-o", lib_path.string()}) != 0) { std::fprintf(stderr, "metallib failed for %s\n", input_path.string().c_str()); diff --git a/tools/fetch_moltenvk.py b/tools/fetch_moltenvk.py new file mode 100755 index 000000000..f75a9022b --- /dev/null +++ b/tools/fetch_moltenvk.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fetch the official MoltenVK prebuilts for local Apple builds. + +Homebrew's molten-vk bottle is not built with MoltenVK private API support. +The official release ships a macOS private-API tarball that avoids runtime +warnings around primitive restart on pipelines that disable it. + +This script extracts the macOS dynamic library, the iOS dynamic framework, and +the public MoltenVK headers into third_party/MoltenVK/. That directory is +intentionally gitignored because it contains binary dependencies. +""" + +import argparse +import hashlib +import shutil +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +DEFAULT_VERSION = "v1.4.1" + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEST_DIR = REPO_ROOT / "third_party" / "MoltenVK" + + +def _download(url: str, tar_name: str, tmp_dir: Path) -> Path: + tar_path = tmp_dir / tar_name + print(f"Downloading {url}") + with urllib.request.urlopen(url) as response, open(tar_path, "wb") as tar_file: + shutil.copyfileobj(response, tar_file) + return tar_path + + +def _extract_member_to_file(tar: tarfile.TarFile, member_path: str, + output_path: Path) -> None: + member = tar.getmember(member_path) + with tar.extractfile(member) as src: + if src is None: + raise RuntimeError(f"{member_path} is missing in tarball") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(src.read()) + + +def _extract_headers(tar: tarfile.TarFile) -> None: + for header_path in ( + "MoltenVK/MoltenVK/include/MoltenVK/mvk_config.h", + "MoltenVK/MoltenVK/include/MoltenVK/mvk_datatypes.h", + "MoltenVK/MoltenVK/include/MoltenVK/mvk_deprecated_api.h", + "MoltenVK/MoltenVK/include/MoltenVK/mvk_private_api.h", + "MoltenVK/MoltenVK/include/MoltenVK/mvk_vulkan.h", + "MoltenVK/MoltenVK/include/MoltenVK/vk_mvk_moltenvk.h", + ): + try: + _extract_member_to_file( + tar, header_path, + DEST_DIR / "include" / "MoltenVK" / Path(header_path).name) + except KeyError: + continue + + +def _extract_directory(tar: tarfile.TarFile, source_prefix: str, + output_dir: Path) -> None: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + matched = False + for member in tar.getmembers(): + if not member.name.startswith(source_prefix): + continue + relative = Path(member.name[len(source_prefix):]) + if not relative.parts: + continue + matched = True + destination = output_dir / relative + if member.isdir(): + destination.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile(): + continue + with tar.extractfile(member) as src: + if src is None: + continue + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(src.read()) + if not matched: + raise RuntimeError(f"{source_prefix} is missing in tarball") + + +def _fetch_macos(version: str, tmp_dir: Path) -> None: + macos_url = ( + "https://github.com/KhronosGroup/MoltenVK/releases/download/" + f"{version}/MoltenVK-macos-privateapi.tar" + ) + + tar_path = _download(macos_url, "MoltenVK-macos-privateapi.tar", tmp_dir) + print("Extracting macOS dylib and headers") + with tarfile.open(tar_path, "r") as tar: + _extract_member_to_file( + tar, + "MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib", + DEST_DIR / "lib" / "libMoltenVK.dylib") + _extract_headers(tar) + + dylib_path = DEST_DIR / "lib" / "libMoltenVK.dylib" + digest = hashlib.sha256(dylib_path.read_bytes()).hexdigest()[:16] + print(f"Installed {dylib_path} (sha256: {digest}...)") + + +def _fetch_ios(version: str, tmp_dir: Path) -> None: + ios_url = ( + "https://github.com/KhronosGroup/MoltenVK/releases/download/" + f"{version}/MoltenVK-ios.tar" + ) + + tar_path = _download(ios_url, "MoltenVK-ios.tar", tmp_dir) + print("Extracting iOS framework and headers") + with tarfile.open(tar_path, "r") as tar: + _extract_directory( + tar, + "MoltenVK/MoltenVK/dynamic/MoltenVK.xcframework/ios-arm64/" + "MoltenVK.framework/", + DEST_DIR / "ios" / "MoltenVK.framework") + _extract_headers(tar) + + framework_binary = DEST_DIR / "ios" / "MoltenVK.framework" / "MoltenVK" + digest = hashlib.sha256(framework_binary.read_bytes()).hexdigest()[:16] + print(f"Installed {framework_binary} (sha256: {digest}...)") + + +def fetch(version: str, platform: str) -> None: + DEST_DIR.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + if platform in ("all", "macos"): + _fetch_macos(version, tmp_dir) + if platform in ("all", "ios"): + _fetch_ios(version, tmp_dir) + + (DEST_DIR / "VERSION").write_text(version + "\n") + print("Re-run CMake/build to bundle the local MoltenVK artifact into the app.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--version", + default=DEFAULT_VERSION, + help=f"MoltenVK release tag to fetch (default: {DEFAULT_VERSION}).", + ) + parser.add_argument( + "--platform", + choices=("all", "macos", "ios"), + default="all", + help="Apple platform artifact to fetch (default: all).", + ) + args = parser.parse_args() + try: + fetch(args.version, args.platform) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/package_ios_ipa.sh b/tools/package_ios_ipa.sh new file mode 100644 index 000000000..da5f8a5ed --- /dev/null +++ b/tools/package_ios_ipa.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/package_ios_ipa.sh APP_BUNDLE REQUESTED_ENTITLEMENTS IPA_OUT [REQUESTED_OUT] [SIGNED_OUT] + +Packages an iOS .app bundle as an IPA and emits entitlement helper files: +- REQUESTED_OUT: the entitlements plist the build asks codesign/Xcode to use. +- SIGNED_OUT: the entitlements extracted from the app's current code signature. + +The IPA is still only installable on stock iOS after a real signing tool +re-signs it with a provisioning profile that permits the requested entitlements. +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +if [ "$#" -lt 3 ] || [ "$#" -gt 5 ]; then + usage >&2 + exit 1 +fi + +app_bundle="$1" +requested_entitlements="$2" +ipa_out="$3" +requested_out="${4:-}" +signed_out="${5:-}" + +[ -d "$app_bundle" ] || die "app bundle not found: $app_bundle" +[ -f "$requested_entitlements" ] || die "requested entitlements not found: $requested_entitlements" +command -v ditto >/dev/null 2>&1 || die "missing required tool: ditto" +command -v codesign >/dev/null 2>&1 || die "missing required tool: codesign" + +ipa_dir="$(cd "$(dirname "$ipa_out")" && pwd)" +ipa_base="$(basename "$ipa_out")" +ipa_out="$ipa_dir/$ipa_base" +mkdir -p "$ipa_dir" + +if [ -n "$requested_out" ]; then + requested_dir="$(dirname "$requested_out")" + mkdir -p "$requested_dir" + cp -f "$requested_entitlements" "$requested_out" +fi + +if [ -n "$signed_out" ]; then + signed_dir="$(dirname "$signed_out")" + mkdir -p "$signed_dir" + if ! codesign -d --entitlements :- "$app_bundle" >"$signed_out.tmp" 2>/dev/null; then + rm -f "$signed_out.tmp" + die "failed to extract signed entitlements from $app_bundle" + fi + mv -f "$signed_out.tmp" "$signed_out" +fi + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/xenios_ipa.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/Payload" +ditto "$app_bundle" "$tmp/Payload/$(basename "$app_bundle")" +rm -f "$ipa_out" +(cd "$tmp" && ditto -c -k --sequesterRsrc --keepParent Payload "$ipa_out") + +echo "IPA: $ipa_out" +if [ -n "$requested_out" ]; then + echo "Requested entitlements: $requested_out" +fi +if [ -n "$signed_out" ]; then + echo "Signed entitlements: $signed_out" +fi diff --git a/xenia-appRelease.entitlements b/xenia-appRelease.entitlements new file mode 100644 index 000000000..16218bd9d --- /dev/null +++ b/xenia-appRelease.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.kernel.extended-virtual-addressing + + com.apple.developer.kernel.increased-memory-limit + + com.apple.security.files.user-selected.read-write + + + diff --git a/xenia-build.py b/xenia-build.py index f542a5e65..40cc40cdc 100755 --- a/xenia-build.py +++ b/xenia-build.py @@ -557,17 +557,27 @@ def normalize_target_arch(value): raise ArgumentTypeError( f"unknown architecture '{value}' (expected: arm64, aarch64, a64, x64, amd64, x86_64, x86)") +def normalize_target_os(value): + """Normalizes --target-os values to canonical names.""" + v = value.lower() + if v in ("ios", "iphoneos"): + return "ios" + raise ArgumentTypeError( + f"unknown target OS '{value}' (expected: ios or iphoneos)") + def is_amd64(): return normalize_target_arch(platform.machine()) in ("x64", "x86_64", "amd64", "x86") def is_arm(): return normalize_target_arch(platform.machine()) in ("x64", "x86_64", "amd64", "x86") -def get_build_dir(target_arch=None): +def get_build_dir(target_arch=None, target_os=None): """Returns the Ninja build directory for the given target architecture. Uses a separate directory when cross-compiling to avoid cache conflicts. """ + if target_os == "ios": + return "build-ios" is_native_arm64 = platform.machine() in ("ARM64", "aarch64", "arm64") if target_arch == "arm64" and not is_native_arm64: return "build-arm64" @@ -580,7 +590,9 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, disable_lto=False, enable_profiler=False, enable_itrace=False, enable_dtrace=False, enable_ftrace=False, - target_arch=None, config=None): + target_arch=None, target_os=None, config=None, + build_dir=None, configuration_types=None, + xcode_generate_schemes=False): """Runs `cmake` to (re)configure build/ from the source root. Uses Ninja Multi-Config by default on all platforms. On Linux the @@ -595,6 +607,15 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, (arm64↔x86_64 via clang's -arch and CMAKE_OSX_ARCHITECTURES) into a separate build-/ tree; Linux rejects non-native target_arch. """ + if target_os == "ios": + if sys.platform != "darwin": + print_error("--target-os=ios is only supported from macOS.") + return 1 + if target_arch not in (None, "arm64"): + print_error("--target-os=ios only supports arm64 device builds.") + return 1 + target_arch = "arm64" + # Cross-compilation via --target-arch is only supported on Windows # (MSVC cross toolchain) and macOS (universal clang). On Linux it would # silently produce a native build in a differently-named directory. @@ -610,7 +631,8 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, if not generator: generator = "Ninja Multi-Config" - build_dir = get_build_dir(target_arch) + if not build_dir: + build_dir = get_build_dir(target_arch, target_os) args = [ "cmake", "-S", ".", @@ -623,7 +645,15 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, f"-DCMAKE_C_COMPILER={c_compiler}", f"-DCMAKE_CXX_COMPILER={cxx_compiler}", ] - if sys.platform == "darwin" and target_arch is not None: + if target_os == "ios": + args += [ + "-DCMAKE_SYSTEM_NAME=iOS", + "-DCMAKE_SYSTEM_PROCESSOR=arm64", + "-DCMAKE_OSX_SYSROOT=iphoneos", + "-DCMAKE_OSX_ARCHITECTURES=arm64", + "-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY", + ] + elif sys.platform == "darwin" and target_arch is not None: # Apple clang is universal; CMAKE_OSX_ARCHITECTURES drives -arch. # We don't set CMAKE_SYSTEM_PROCESSOR here — without # CMAKE_SYSTEM_NAME, CMake re-derives it from the host. Our @@ -686,6 +716,10 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, args += [f"-DXENIA_ENABLE_FTRACE={'ON' if enable_ftrace else 'OFF'}"] if config: args += [f"-DCMAKE_BUILD_TYPE={config.title()}"] + if configuration_types: + args += [f"-DCMAKE_CONFIGURATION_TYPES={configuration_types}"] + if xcode_generate_schemes: + args += ["-DCMAKE_XCODE_GENERATE_SCHEME=ON"] ret = subprocess.call(args) if ret == 0: generate_version_h(build_dir) @@ -703,13 +737,13 @@ def get_build_bin_path(args): A full path for the bin folder. """ if sys.platform == "darwin": - platform_name = "macosx" + platform_name = "iOS" if args.get("target_os") == "ios" else "macOS" elif sys.platform == "win32": - platform_name = "windows" + platform_name = "Windows" else: - platform_name = "linux" - build_dir = get_build_dir(args.get("target_arch")) - return os.path.join(self_path, build_dir, "bin", platform_name.capitalize(), args["config"].capitalize()) + platform_name = "Linux" + build_dir = get_build_dir(args.get("target_arch"), args.get("target_os")) + return os.path.join(self_path, build_dir, "bin", platform_name, args["config"].capitalize()) def create_clion_workspace(): @@ -826,6 +860,10 @@ class SetupCommand(Command): help="Target architecture (arm64/aarch64/a64, x64/amd64/x86_64/x86). " "On Windows and macOS, non-native values enable cross-compilation " "into a separate build-/ tree.") + self.parser.add_argument( + "--target-os", type=normalize_target_os, default=None, + help="Target OS override. Currently supports device-only iOS " + "(ios/iphoneos) from macOS.") def execute(self, args, pass_args, cwd): print("Setting up the build environment...\n") @@ -838,7 +876,8 @@ class SetupCommand(Command): print_warning("Git not available or not a repository. Dependencies may be missing.") print("\n- running cmake configure...") - ret = run_cmake_configure(target_arch=args.get("target_arch")) + ret = run_cmake_configure(target_arch=args.get("target_arch"), + target_os=args.get("target_os")) print_status(ResultStatus.SUCCESS if not ret else ResultStatus.FAILURE) return ret @@ -926,9 +965,14 @@ class BaseBuildCommand(Command): help="Target architecture (arm64/aarch64/a64, x64/amd64/x86_64/x86). " "On Windows and macOS, non-native values enable cross-compilation " "into a separate build-/ tree.") + self.parser.add_argument( + "--target-os", type=normalize_target_os, default=None, + help="Target OS override. Currently supports device-only iOS " + "(ios/iphoneos) from macOS.") def execute(self, args, pass_args, cwd): target_arch = args.get("target_arch") + target_os = args.get("target_os") if not args["no_configure"]: print("- running cmake configure...") ret = run_cmake_configure( @@ -940,13 +984,14 @@ class BaseBuildCommand(Command): enable_dtrace=args["enable_dtrace"], enable_ftrace=args["enable_ftrace"], target_arch=target_arch, + target_os=target_os, config=args["config"], ) if ret: return ret print("") - build_dir = get_build_dir(target_arch) + build_dir = get_build_dir(target_arch, target_os) print("- building (%s):%s..." % ( "all" if not len(args["target"]) else ", ".join(args["target"]), args["config"])) @@ -1531,6 +1576,10 @@ class DevenvCommand(Command): help="Target architecture (arm64/aarch64/a64, x64/amd64/x86_64/x86). " "On Windows, non-native values enable cross-compilation into a " "separate build-vs-/ tree.") + self.parser.add_argument( + "--target-os", type=normalize_target_os, default=None, + help="Target OS override. Currently supports device-only iOS " + "(ios/iphoneos) from macOS.") self.parser.add_argument( "--config", choices=["checked", "debug", "release"], default="debug", type=str.lower, @@ -1540,6 +1589,9 @@ class DevenvCommand(Command): def execute(self, args, pass_args, cwd): target_arch = args.get("target_arch") + target_os = args.get("target_os") + if sys.platform == "darwin" and target_os == "ios": + return self._launch_xcode_ios(args["config"]) if sys.platform == "win32": return self._launch_visual_studio(target_arch, args["config"]) # Non-Windows: CLion is the only IDE we know how to launch @@ -1596,6 +1648,37 @@ class DevenvCommand(Command): shell_call(["devenv", sln_path]) return 0 + def _launch_xcode_ios(self, config="debug"): + """Configures a separate Xcode iOS build tree, then opens it.""" + if not has_bin("xcodebuild"): + print_error("Xcode command line tools are not available.") + return 1 + + build_dir = "build-ios-xcode" + config_title = config.title() + print(f"Configuring Xcode iOS build tree ({config_title}) in {build_dir}...") + ret = run_cmake_configure( + generator="Xcode", + target_arch="arm64", + target_os="ios", + build_dir=build_dir, + configuration_types="Checked;Debug;Release", + xcode_generate_schemes=True, + ) + if ret != 0: + print_error("cmake configure failed for the Xcode iOS build tree") + return ret + + project_path = os.path.join(build_dir, "xenia.xcodeproj") + if not os.path.exists(project_path): + print_error("cmake configured successfully but no xenia.xcodeproj was produced") + return 1 + + print(f"\n- launching Xcode on {project_path}...") + print(" Select the xenia-app scheme and a physical iOS device.") + shell_call(["open", project_path]) + return 0 + if __name__ == "__main__": main() diff --git a/xenia_ios.entitlements b/xenia_ios.entitlements new file mode 100644 index 000000000..619f11c74 --- /dev/null +++ b/xenia_ios.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.kernel.increased-memory-limit + + com.apple.security.files.user-selected.read-write + + +