diff --git a/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp b/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp index 2d51b557a3..5e0cebcf64 100644 --- a/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp +++ b/pcsx2/GS/Renderers/HW/GSTextureReplacements.cpp @@ -127,6 +127,7 @@ namespace GSTextureReplacements static void WorkerThreadEntryPoint(); static void SyncWorkerThread(); static void CancelPendingLoadsAndDumps(); + static void NotifyStartupCompleteForCurrentGame(); static std::string s_current_serial; @@ -503,6 +504,7 @@ static bool GetWrongCasePath(std::string* output, const char* dir, std::string_v void GSTextureReplacements::ReloadReplacementMap() { SyncWorkerThread(); + ScopedGuard startup_complete_guard([]() { NotifyStartupCompleteForCurrentGame(); }); // clear out the caches { @@ -593,6 +595,31 @@ void GSTextureReplacements::ReloadReplacementMap() } } +void GSTextureReplacements::NotifyStartupCompleteForCurrentGame() +{ + const std::string serial = s_current_serial; + if (serial.empty() || serial != VMManager::GetDiscSerial()) + return; + + // Without precaching there is no finite startup decode phase: replacement images are + // intentionally loaded on demand. In that configuration, indexing the active game's + // replacement map is the complete startup boundary. + if (!GSConfig.LoadTextureReplacements || !GSConfig.PrecacheTextureReplacements || + s_replacement_texture_filenames.empty()) + { + VMManager::NotifyTextureReplacementStartupComplete(); + return; + } + + // PrecacheReplacementTextures() queues every startup decode before this barrier. + // The serial check prevents a stale barrier from completing readiness after a disc + // change or a replacement-map reload for another game. + QueueWorkerThreadItem([serial]() { + if (serial == VMManager::GetDiscSerial()) + VMManager::NotifyTextureReplacementStartupComplete(); + }, false); +} + void GSTextureReplacements::UpdateConfig(Pcsx2Config::GSOptions& old_config) { // get rid of worker thread if it's no longer needed diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index 880517312e..616b362235 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -81,6 +81,10 @@ #include "common/Darwin/DarwinMisc.h" #endif +#if defined(__APPLE__) && TARGET_OS_IPHONE +extern "C" void ARMSX2_PostEmulationOnlyStartupReady(void); +#endif + namespace VMManager { static void SetDefaultLoggingSettings(SettingsInterface& si); @@ -160,9 +164,20 @@ static bool s_log_block_system_console = false; static bool s_log_force_file_log = false; static std::atomic s_state{VMState::Shutdown}; +static std::atomic_bool s_emulation_only_mode{false}; +static std::atomic s_emulation_only_release_flags{VMManager::EMULATION_ONLY_RELEASE_ALL}; +static std::atomic_bool s_boot_patches_applied{false}; +static std::atomic_bool s_texture_replacement_startup_complete{false}; +static std::atomic_bool s_emulation_only_startup_notification_posted{false}; static bool s_cpu_implementation_changed = false; static Threading::ThreadHandle s_vm_thread_handle; +static bool ArePatchesDisabledByEmulationOnlyMode() +{ + return s_emulation_only_mode.load(std::memory_order_acquire) && + (s_emulation_only_release_flags.load(std::memory_order_acquire) & VMManager::EMULATION_ONLY_RELEASE_PATCHES) != 0; +} + static std::deque s_save_state_threads; static std::mutex s_save_state_threads_mutex; @@ -814,6 +829,8 @@ void VMManager::ApplySettings() if (vtlb_FastmemAreaUnavailable() && EmuConfig.Cpu.Recompiler.EnableFastmem) EmuConfig.Cpu.Recompiler.EnableFastmem = false; CheckForConfigChanges(old_config); + if (s_emulation_only_mode.load(std::memory_order_acquire)) + ReleaseNonEssentialRuntimeResources(s_emulation_only_release_flags.load(std::memory_order_acquire)); } void VMManager::ApplyCoreSettings() @@ -845,6 +862,8 @@ void VMManager::ApplyCoreSettings() } CheckForConfigChanges(old_config); + if (s_emulation_only_mode.load(std::memory_order_acquire)) + ReleaseNonEssentialRuntimeResources(s_emulation_only_release_flags.load(std::memory_order_acquire)); } bool VMManager::ReloadGameSettings() @@ -924,7 +943,8 @@ void VMManager::Internal::UpdateEmuFolders() if (VMManager::HasValidVM()) { - if (EmuFolders::Cheats != old_cheats_directory || EmuFolders::Patches != old_patches_directory) + if ((EmuFolders::Cheats != old_cheats_directory || EmuFolders::Patches != old_patches_directory) && + !ArePatchesDisabledByEmulationOnlyMode()) Patch::ReloadPatches(s_disc_serial, s_current_crc, true, false, true, true); if (EmuFolders::MemoryCards != old_memcards_directory) @@ -1196,7 +1216,10 @@ void VMManager::UpdateDiscDetails(bool booting) ApplySettings(); // Patches are game-dependent, thus should get applied after game settings ia loaded. - Patch::ReloadPatches(s_disc_serial, HasBootedELF() ? s_current_crc : 0, true, true, false, false); + if (!ArePatchesDisabledByEmulationOnlyMode()) + { + Patch::ReloadPatches(s_disc_serial, HasBootedELF() ? s_current_crc : 0, true, true, false, false); + } ReportGameChangeToHost(); if (MTGS::IsOpen()) @@ -1233,7 +1256,8 @@ void VMManager::HandleELFChange(bool verbose_patches_if_changed) Achievements::GameChanged(s_disc_crc, crc_to_report); Console.WriteLn(Color_StrongOrange, fmt::format("ELF changed, active CRC {:08X} ({})", crc_to_report, s_elf_path)); - Patch::ReloadPatches(s_disc_serial, crc_to_report, false, false, false, verbose_patches_if_changed); + if (!ArePatchesDisabledByEmulationOnlyMode()) + Patch::ReloadPatches(s_disc_serial, crc_to_report, false, false, false, verbose_patches_if_changed); ApplyCoreSettings(); } @@ -1397,6 +1421,12 @@ VMBootResult VMManager::Initialize(const VMBootParameters& boot_params, Error* e return VMBootResult::StartupFailure; } + s_emulation_only_mode.store(false, std::memory_order_release); + s_emulation_only_release_flags.store(EMULATION_ONLY_RELEASE_ALL, std::memory_order_release); + s_boot_patches_applied.store(false, std::memory_order_release); + s_texture_replacement_startup_complete.store(false, std::memory_order_release); + s_emulation_only_startup_notification_posted.store(false, std::memory_order_release); + // cancel any game list scanning, we need to use CDVD! // TODO: we can get rid of this once, we make CDVD not use globals... // (or make it thread-local, but that seems silly.) @@ -2950,7 +2980,8 @@ void VMManager::Internal::ELFLoadingOnCPUThread(std::string elf_path) // Remove patches, if we're changing games, we don't want to be applying the patch for the old game while it's loading. if (!was_running_bios) { - Patch::ReloadPatches(s_disc_serial, 0, false, false, false, true); + if (!ArePatchesDisabledByEmulationOnlyMode()) + Patch::ReloadPatches(s_disc_serial, 0, false, false, false, true); ApplyCoreSettings(); } } @@ -2975,6 +3006,7 @@ void VMManager::Internal::EntryPointCompilingOnCPUThread() HandleELFChange(true); Patch::ApplyBootPatches(); + NotifyBootPatchesApplied(); // If the config changes at this point, it's a reset, so the game doesn't currently know about the memcard // so there's no need to leave the eject running. @@ -3228,7 +3260,7 @@ void VMManager::CheckForConfigChanges(const Pcsx2Config& old_config) void VMManager::ReloadPatches(bool reload_files, bool reload_enabled_list, bool verbose, bool verbose_if_changed) { - if (!HasValidVM()) + if (!HasValidVM() || ArePatchesDisabledByEmulationOnlyMode()) return; Patch::ReloadPatches(s_disc_serial, HasBootedELF() ? s_current_crc : 0, reload_files, reload_enabled_list, verbose, verbose_if_changed); @@ -3238,6 +3270,112 @@ void VMManager::ReloadPatches(bool reload_files, bool reload_enabled_list, bool ApplyCoreSettings(); } +bool VMManager::IsEmulationOnlyMode() +{ + return s_emulation_only_mode.load(std::memory_order_acquire); +} + +static void TryPostEmulationOnlyStartupReady() +{ + if (!s_boot_patches_applied.load(std::memory_order_acquire) || + !s_texture_replacement_startup_complete.load(std::memory_order_acquire)) + { + return; + } + + bool expected = false; + if (!s_emulation_only_startup_notification_posted.compare_exchange_strong( + expected, true, std::memory_order_acq_rel, std::memory_order_acquire)) + { + return; + } + +#if defined(__APPLE__) && TARGET_OS_IPHONE + ARMSX2_PostEmulationOnlyStartupReady(); +#endif +} + +void VMManager::NotifyBootPatchesApplied() +{ + s_boot_patches_applied.store(true, std::memory_order_release); + TryPostEmulationOnlyStartupReady(); +} + +void VMManager::NotifyTextureReplacementStartupComplete() +{ + s_texture_replacement_startup_complete.store(true, std::memory_order_release); + TryPostEmulationOnlyStartupReady(); +} + +void VMManager::ReleaseNonEssentialRuntimeResources(u32 release_flags) +{ + if (!HasValidVM()) + return; + + release_flags &= EMULATION_ONLY_RELEASE_ALL; + s_emulation_only_release_flags.store(release_flags, std::memory_order_release); + s_emulation_only_mode.store(true, std::memory_order_release); + + // These are runtime copies. Persistent user settings are intentionally unchanged and + // LoadSettings() restores them for the next VM session. + if (release_flags & EMULATION_ONLY_RELEASE_PINE) + EmuConfig.EnablePINE = false; + if (release_flags & EMULATION_ONLY_RELEASE_DISCORD_PRESENCE) + EmuConfig.EnableDiscordPresence = false; + if (release_flags & EMULATION_ONLY_RELEASE_ACHIEVEMENTS) + EmuConfig.Achievements.Enabled = false; + if (release_flags & EMULATION_ONLY_RELEASE_PATCHES) + { + EmuConfig.EnablePatches = false; + EmuConfig.EnableCheats = false; + EmuConfig.EnableWideScreenPatches = false; + EmuConfig.EnableNoInterlacingPatches = false; + } + + if (release_flags & EMULATION_ONLY_RELEASE_PINE) + PINEServer::Deinitialize(); + if (release_flags & EMULATION_ONLY_RELEASE_DISCORD_PRESENCE) + ShutdownDiscordPresence(); + if (release_flags & EMULATION_ONLY_RELEASE_ACHIEVEMENTS) + Achievements::Shutdown(false); + + if ((release_flags & EMULATION_ONLY_RELEASE_INPUT_RECORDING) && g_InputRecording.isActive()) + g_InputRecording.stop(); + + if (release_flags & EMULATION_ONLY_RELEASE_PATCHES) + Patch::UnloadPatches(); + + if (release_flags & EMULATION_ONLY_RELEASE_OSD) + { + Host::ClearOSDMessages(); + + // Disable every live OSD feed without persisting the user's selected preset. + GSConfig.OsdMessagesPos = OsdOverlayPos::None; + GSConfig.OsdPerformancePos = OsdOverlayPos::None; + GSConfig.OsdShowSpeed = false; + GSConfig.OsdShowFPS = false; + GSConfig.OsdShowVPS = false; + GSConfig.OsdShowResolution = false; + GSConfig.OsdShowGSStats = false; + GSConfig.OsdShowCPU = false; + GSConfig.OsdShowGPU = false; + GSConfig.OsdShowGPUDebug = false; + GSConfig.OsdShowGPUStats = false; + GSConfig.OsdShowIndicators = false; + GSConfig.OsdShowFrameTimes = false; + GSConfig.OsdShowHardwareInfo = false; + GSConfig.OsdShowVersion = false; + GSConfig.OsdShowSettings = false; + GSConfig.OsdshowPatches = false; + GSConfig.OsdShowInputs = false; + GSConfig.OsdShowVideoCapture = false; + GSConfig.OsdShowInputRec = false; + GSConfig.OsdShowTextureReplacements = false; + } + + Console.WriteLn("Emulation-only mode: released optional runtime services and overlays."); +} + void VMManager::EnforceAchievementsChallengeModeSettings() { if (!Achievements::IsHardcoreModeActive()) diff --git a/pcsx2/VMManager.h b/pcsx2/VMManager.h index 09b6b87ad7..dfdec55566 100644 --- a/pcsx2/VMManager.h +++ b/pcsx2/VMManager.h @@ -70,6 +70,19 @@ namespace VMManager { /// The number of usable save state slots. static constexpr s32 NUM_SAVE_STATE_SLOTS = 10; + static constexpr u32 EMULATION_ONLY_RELEASE_PATCHES = (1u << 0); + static constexpr u32 EMULATION_ONLY_RELEASE_DISCORD_PRESENCE = (1u << 1); + static constexpr u32 EMULATION_ONLY_RELEASE_PINE = (1u << 2); + static constexpr u32 EMULATION_ONLY_RELEASE_ACHIEVEMENTS = (1u << 3); + static constexpr u32 EMULATION_ONLY_RELEASE_INPUT_RECORDING = (1u << 4); + static constexpr u32 EMULATION_ONLY_RELEASE_OSD = (1u << 5); + static constexpr u32 EMULATION_ONLY_RELEASE_ALL = + EMULATION_ONLY_RELEASE_PATCHES | + EMULATION_ONLY_RELEASE_DISCORD_PRESENCE | + EMULATION_ONLY_RELEASE_PINE | + EMULATION_ONLY_RELEASE_ACHIEVEMENTS | + EMULATION_ONLY_RELEASE_INPUT_RECORDING | + EMULATION_ONLY_RELEASE_OSD; /// The stack size to use for threads running recompilers static constexpr std::size_t EMU_THREAD_STACK_SIZE = 2 * 1024 * 1024; // µVU likes recursion @@ -148,6 +161,20 @@ namespace VMManager /// Reloads game patches. void ReloadPatches(bool reload_files, bool reload_enabled_list, bool verbose, bool verbose_if_changed); + /// Stops and releases optional runtime services while preserving the active VM, + /// renderer, audio, storage devices, networking devices, and controller input. + void ReleaseNonEssentialRuntimeResources(u32 release_flags); + + /// Returns true after optional resources have been released for the current VM session. + bool IsEmulationOnlyMode(); + + /// Marks the active game's boot patches as applied. iOS waits for this and texture + /// replacement startup before automatically entering emulation-only mode. + void NotifyBootPatchesApplied(); + + /// Marks the active game's replacement-texture map/precache startup as complete. + void NotifyTextureReplacementStartupComplete(); + /// Reloads input sources. void ReloadInputSources(); diff --git a/platforms/ios/app/src/main/cpp/ARMSX2Bridge.h b/platforms/ios/app/src/main/cpp/ARMSX2Bridge.h index 9d64d43020..1cb7410d43 100644 --- a/platforms/ios/app/src/main/cpp/ARMSX2Bridge.h +++ b/platforms/ios/app/src/main/cpp/ARMSX2Bridge.h @@ -88,6 +88,7 @@ typedef void (^ARMSX2RetroAchievementsCompletion)(BOOL success, NSString * _Nonn + (nonnull NSString *)buildVersion; + (BOOL)isJITAvailable; + (BOOL)isNoJITFallbackActive; ++ (BOOL)isIdleVMPrewarmResolved; + (nonnull NSArray *)extractControllerSkinArchiveAtURL:(nonnull NSURL *)archiveURL toDirectory:(nonnull NSURL *)destinationDirectory NS_SWIFT_NAME(extractControllerSkinArchive(at:to:)); @@ -108,6 +109,9 @@ typedef void (^ARMSX2RetroAchievementsCompletion)(BOOL success, NSString * _Nonn + (BOOL)isPerformanceOverlayVisible; + (void)applyOsdPreset:(int)preset; // 0=off, 1=simple, 2=detail, 3=full +// Permanently releases the selected optional runtime resources for the current VM session. ++ (void)releaseNonEmulationResources:(NSUInteger)releaseFlags; + // Accessibility: structured device stats for the VoiceOver HUD mirror. + (nonnull NSDictionary *)deviceStatsForAccessibility; diff --git a/platforms/ios/app/src/main/cpp/ARMSX2Bridge.mm b/platforms/ios/app/src/main/cpp/ARMSX2Bridge.mm index 744c55dc63..9cb3a5c9fc 100644 --- a/platforms/ios/app/src/main/cpp/ARMSX2Bridge.mm +++ b/platforms/ios/app/src/main/cpp/ARMSX2Bridge.mm @@ -77,6 +77,7 @@ extern INISettingsInterface* g_p44_settings_interface; extern "C" void ARMSX2_PrepareGameRenderViewForCurrentRenderer(const char* reason); extern "C" void ARMSX2_PostRuntimeMenuStateChanged(void); extern "C" void ARMSX2_iOSTestGamepadRumble(void); +extern "C" bool ARMSX2_IsIdleVMPrewarmResolved(void); // Coalesce base-settings INI writes so rapid changes (slider drags, preset bursts, // repeated toggles) persist to disk once per short window instead of once per call. @@ -2266,6 +2267,10 @@ static std::string ARMSX2PerGameSettingsPath(const std::string& serial, u32 crc) return DarwinMisc::iPSX2_FORCE_EE_INTERP != 0; } ++ (BOOL)isIdleVMPrewarmResolved { + return ARMSX2_IsIdleVMPrewarmResolved() ? YES : NO; +} + + (nonnull NSArray *)extractControllerSkinArchiveAtURL:(nonnull NSURL *)archiveURL toDirectory:(nonnull NSURL *)destinationDirectory { static const zip_uint64_t kMaxSkinArchiveEntryBytes = 16 * 1024 * 1024; @@ -3196,6 +3201,9 @@ static std::string ARMSX2PerGameSettingsPath(const std::string& serial, u32 crc) } + (void)triggerDeviceHapticLarge:(NSUInteger)large small:(NSUInteger)small { + if (VMManager::IsEmulationOnlyMode()) + return; + // GameEventHaptics is @MainActor-isolated; dispatch to the main queue. dispatch_async(dispatch_get_main_queue(), ^{ #if ARMSX2_HAS_SWIFTUI_HOST @@ -3207,6 +3215,22 @@ static std::string ARMSX2PerGameSettingsPath(const std::string& serial, u32 crc) }); } ++ (void)releaseNonEmulationResources:(NSUInteger)releaseFlags { + if (releaseFlags & VMManager::EMULATION_ONLY_RELEASE_ACHIEVEMENTS) { + void (^clearPendingNotification)(void) = ^{ + s_pendingRetroAchievementsNotification = nil; + }; + if ([NSThread isMainThread]) + clearPendingNotification(); + else + dispatch_async(dispatch_get_main_queue(), clearPendingNotification); + } + + Host::RunOnCPUThread([releaseFlags]() { + VMManager::ReleaseNonEssentialRuntimeResources(static_cast(releaseFlags)); + }, false); +} + // Apply OSD preset — sets ALL GSConfig flags to match the preset + (void)applyOsdPreset:(int)preset { // Clear everything first diff --git a/platforms/ios/app/src/main/cpp/IOS/SceneDelegate.mm b/platforms/ios/app/src/main/cpp/IOS/SceneDelegate.mm index 970c5eadb8..0d867dff47 100644 --- a/platforms/ios/app/src/main/cpp/IOS/SceneDelegate.mm +++ b/platforms/ios/app/src/main/cpp/IOS/SceneDelegate.mm @@ -620,6 +620,28 @@ static std::atomic s_jitExpired{false}; // when revalidation decides to tear the old thread down and create a new one. static std::atomic s_vmInitComplete{false}; static std::atomic s_vmThreadShouldExit{false}; +static std::atomic s_idleVMPrewarmResolved{false}; + +static void ARMSX2ResolveIdleVMPrewarm() +{ + bool expected = false; + if (!s_idleVMPrewarmResolved.compare_exchange_strong(expected, true)) + return; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] + postNotificationName:@"ARMSX2iOSIdleVMPrewarmResolved" object:nil]; + }); +} + +extern "C" bool ARMSX2_IsIdleVMPrewarmResolved() +{ +#if TARGET_OS_SIMULATOR + return true; +#else + return s_idleVMPrewarmResolved.load(std::memory_order_acquire); +#endif +} static void ARMSX2StopJITKeepalive() { @@ -713,6 +735,28 @@ static void ARMSX2StartJITKeepalive() #pragma mark - Persistent VM thread - (void)startVMThread { + [self startVMThreadRequestingBoot:YES]; +} + +- (void)prepareVMThreadForIdle { +#if !TARGET_OS_SIMULATOR + ARMSX2ApplyJITScriptProtocol("idle-vm-prewarm"); + const bool jitAlive = DarwinMisc::IsJITAvailable() && DarwinMisc::ValidateJITAlive(); + if (!jitAlive) { + std::fprintf(stderr, "@@BOOT_IDLE_PREWARM@@ jit=0 action=skip\n"); + std::fflush(stderr); + ARMSX2ResolveIdleVMPrewarm(); + return; + } + + DarwinMisc::iPSX2_FORCE_EE_INTERP = 0; + std::fprintf(stderr, "@@BOOT_IDLE_PREWARM@@ jit=1 action=prepare\n"); + std::fflush(stderr); + [self startVMThreadRequestingBoot:NO]; +#endif +} + +- (void)startVMThreadRequestingBoot:(BOOL)requestBoot { ARMSX2ApplyJITScriptProtocol("start-vm-thread"); // Set inside the lock below when the JIT-dead re-boot path tears the old // thread down; consumed after the lock is released so the 200ms sleep does @@ -727,11 +771,22 @@ static void ARMSX2StartJITKeepalive() return; } - // Signal the persistent thread to boot - s_requestVMBoot.store(true); - s_requestVMStop.store(false); + if (requestBoot) { + // Signal the persistent thread to boot. Idle preparation deliberately + // leaves this false so the initialized worker blocks in its wait loop. + s_requestVMBoot.store(true); + s_requestVMStop.store(false); + } if (s_vmThreadCreated) { + if (!requestBoot) { + std::fprintf(stderr, "@@BOOT_IDLE_PREWARM@@ action=already_prepared\n"); + std::fflush(stderr); + if (s_vmInitComplete.load(std::memory_order_acquire)) + ARMSX2ResolveIdleVMPrewarm(); + return; + } + // Re-validate JIT before signaling the existing thread. // The persistent thread bypasses CPUThreadInitialize, so it reuses // the JIT memory allocated at first boot. If iOS revoked the grant, @@ -795,7 +850,8 @@ static void ARMSX2StartJITKeepalive() s_vmThreadCreated = true; } - std::fprintf(stderr, "@@BOOT_START_THREAD@@ active=0 created=0 action=create\n"); + std::fprintf(stderr, "@@BOOT_START_THREAD@@ active=0 created=0 action=create request_boot=%d\n", + requestBoot ? 1 : 0); std::fflush(stderr); Console.WriteLn("[VM] Creating persistent VM thread..."); @@ -828,6 +884,7 @@ static void ARMSX2StartJITKeepalive() "via StikDebug."); [[NSNotificationCenter defaultCenter] postNotificationName:@"ARMSX2iOSReturnToMenu" object:nil]; }); + ARMSX2ResolveIdleVMPrewarm(); std::lock_guard lk(s_vmMutex); s_vmThreadCreated = false; } @@ -835,6 +892,7 @@ static void ARMSX2StartJITKeepalive() watchdog.detach(); const bool cpuInitOk = VMManager::Internal::CPUThreadInitialize(); s_vmInitComplete.store(true, std::memory_order_relaxed); + ARMSX2ResolveIdleVMPrewarm(); // NOTE (Issue 2, benign race): there is a TOCTOU window here. If the // watchdog fires between CPUThreadInitialize() completing and this point, // it will have already reset s_vmThreadCreated=false (and posted the @@ -1159,6 +1217,13 @@ static void ARMSX2StartJITKeepalive() [rootVC.view setNeedsLayout]; [rootVC.view layoutIfNeeded]; } + + // Prepare the persistent CPU/JIT worker while the launch-time JIT grant is + // fresh, but leave it waiting without a VM boot request. Running this from + // scene activation also retries after returning from a JIT-enabler app. +#if !TARGET_OS_SIMULATOR + [self prepareVMThreadForIdle]; +#endif } - (void)sceneWillResignActive:(UIScene *)scene { diff --git a/platforms/ios/app/src/main/cpp/Info.plist.in b/platforms/ios/app/src/main/cpp/Info.plist.in index ef9e626db3..9869e5952c 100644 --- a/platforms/ios/app/src/main/cpp/Info.plist.in +++ b/platforms/ios/app/src/main/cpp/Info.plist.in @@ -330,6 +330,8 @@ NSLocalNetworkUsageDescription ARMSX2 uses local network access for PS2 online play through DEV9 sockets. + NSMotionUsageDescription + ARMSX2 uses device motion to control the emulated right analog stick when Gyroscope Camera is enabled. UILaunchScreen UIStatusBarStyle @@ -416,6 +418,25 @@ UTExportedTypeDeclarations + + UTTypeIdentifier + com.armsx2.settings-preset + UTTypeDescription + ARMSX2 Settings Preset + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + ini + + public.mime-type + text/plain + + UTTypeIdentifier com.armsx2.skin diff --git a/platforms/ios/app/src/main/cpp/ios_main.mm b/platforms/ios/app/src/main/cpp/ios_main.mm index 8ac2914ec5..9377844133 100644 --- a/platforms/ios/app/src/main/cpp/ios_main.mm +++ b/platforms/ios/app/src/main/cpp/ios_main.mm @@ -970,6 +970,17 @@ extern "C" void ARMSX2_PostRuntimeMenuStateChanged(void) }); } +extern "C" void ARMSX2_PostEmulationOnlyStartupReady(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (VMManager::IsEmulationOnlyMode()) + return; + [[NSNotificationCenter defaultCenter] + postNotificationName:@"ARMSX2iOSEmulationOnlyStartupReady" + object:nil]; + }); +} + // Gamepad button mapping — 16 PS2 buttons → SDL_GamepadButton std::atomic s_captureMode{false}; std::atomic s_capturedButton{-1}; diff --git a/platforms/ios/app/src/main/swift/Models/AppState.swift b/platforms/ios/app/src/main/swift/Models/AppState.swift index 4aa88c7ae7..d3c3f3d2c1 100644 --- a/platforms/ios/app/src/main/swift/Models/AppState.swift +++ b/platforms/ios/app/src/main/swift/Models/AppState.swift @@ -3,11 +3,21 @@ import SwiftUI +struct EmulationOnlyPresentation: Equatable { + var showsVirtualControls = false + var showsQuickMenu = false + var padLayoutSnapshot: PadLayoutSnapshot? + var padSkinDescriptor: VPadSkinDescriptor? + + static let minimal = EmulationOnlyPresentation() +} + @Observable final class AppState: @unchecked Sendable { static let shared = AppState() static let systemChromeNeedsUpdateNotification = Notification.Name("ARMSX2iOSSystemChromeNeedsUpdate") static let releaseMenuBackgroundResourcesNotification = Notification.Name("ARMSX2iOSReleaseMenuBackgroundResources") + static let emulationOnlyStartupReadyNotification = Notification.Name("ARMSX2iOSEmulationOnlyStartupReady") enum Screen { case menu @@ -17,6 +27,9 @@ final class AppState: @unchecked Sendable { var currentScreen: Screen = .menu var selectedTab: Int = 0 var runningGameName: String? = nil + var isEmulationOnlyMode: Bool = false + var emulationOnlyPresentation = EmulationOnlyPresentation.minimal + private(set) var emulationOnlyStartupReady: Bool = false var hideStatusBar: Bool = false { didSet { if oldValue != hideStatusBar { @@ -36,6 +49,7 @@ final class AppState: @unchecked Sendable { @ObservationIgnored private var shutdownObserver: NSObjectProtocol? @ObservationIgnored private var autoBootObserver: NSObjectProtocol? + @ObservationIgnored private var emulationOnlyStartupReadyObserver: NSObjectProtocol? private init() { shutdownObserver = NotificationCenter.default.addObserver( @@ -43,6 +57,9 @@ final class AppState: @unchecked Sendable { object: nil, queue: .main ) { [weak self] _ in self?.runningGameName = nil + self?.isEmulationOnlyMode = false + self?.emulationOnlyPresentation = .minimal + self?.emulationOnlyStartupReady = false if let action = self?.pendingBootAction { self?.pendingBootAction = nil action() @@ -57,13 +74,26 @@ final class AppState: @unchecked Sendable { forName: NSNotification.Name("ARMSX2iOSAutoBootDidStart"), object: nil, queue: .main ) { [weak self] _ in + self?.isEmulationOnlyMode = false + self?.emulationOnlyPresentation = .minimal + self?.emulationOnlyStartupReady = false self?.releaseMenuBackgroundResourcesForGameplay() self?.runningGameName = "AutoBoot" self?.currentScreen = .playing } + + emulationOnlyStartupReadyObserver = NotificationCenter.default.addObserver( + forName: Self.emulationOnlyStartupReadyNotification, + object: nil, queue: .main + ) { [weak self] _ in + self?.emulationOnlyStartupReady = true + } } func bootGame(isoName: String) { + isEmulationOnlyMode = false + emulationOnlyPresentation = .minimal + emulationOnlyStartupReady = false releaseMenuBackgroundResourcesForGameplay() Task { @MainActor in StikDebugLauncher.autoOpenIfNeeded(reason: "game boot") @@ -78,6 +108,9 @@ final class AppState: @unchecked Sendable { } func bootBIOSOnly() { + isEmulationOnlyMode = false + emulationOnlyPresentation = .minimal + emulationOnlyStartupReady = false releaseMenuBackgroundResourcesForGameplay() Task { @MainActor in StikDebugLauncher.autoOpenIfNeeded(reason: "BIOS boot") @@ -95,6 +128,8 @@ final class AppState: @unchecked Sendable { if ARMSX2Bridge.isVMRunning() { ARMSX2Bridge.setVMPaused(true) } + isEmulationOnlyMode = false + emulationOnlyPresentation = .minimal currentScreen = .menu // [P44-2] Restore opaque background on hosting controller NotificationCenter.default.post(name: NSNotification.Name("ARMSX2iOSReturnToMenu"), object: nil) @@ -102,6 +137,8 @@ final class AppState: @unchecked Sendable { func returnToGame() { if runningGameName != nil { + isEmulationOnlyMode = false + emulationOnlyPresentation = .minimal releaseMenuBackgroundResourcesForGameplay() // [P44-2] Clear background so Metal surface shows through NotificationCenter.default.post(name: NSNotification.Name("ARMSX2iOSEnterGameScreen"), object: nil) @@ -134,6 +171,14 @@ final class AppState: @unchecked Sendable { } } + /// Permanently removes the in-game SwiftUI controls and menus for the current VM session. + /// A VM shutdown or a new boot resets this flag and restores the normal gameplay UI. + func enterEmulationOnlyMode(presentation: EmulationOnlyPresentation) { + guard case .playing = currentScreen, emulationOnlyStartupReady else { return } + emulationOnlyPresentation = presentation + isEmulationOnlyMode = true + } + private func releaseMenuBackgroundResourcesForGameplay() { NotificationCenter.default.post( name: Self.releaseMenuBackgroundResourcesNotification, diff --git a/platforms/ios/app/src/main/swift/Models/DynamicThumbstickSettings.swift b/platforms/ios/app/src/main/swift/Models/DynamicThumbstickSettings.swift new file mode 100644 index 0000000000..976a6b0a42 --- /dev/null +++ b/platforms/ios/app/src/main/swift/Models/DynamicThumbstickSettings.swift @@ -0,0 +1,497 @@ +// DynamicThumbstickSettings.swift — Persistent virtual-pad gesture configuration +// SPDX-License-Identifier: GPL-3.0+ + +import Foundation + +enum VirtualPadActionButton: Int, CaseIterable, Hashable, Identifiable { + case leftShoulder + case rightShoulder + case leftTrigger + case rightTrigger + case faceBottom + case faceLeft + case faceRight + case faceTop + case up + case down + case left + case right + case start + case select + case leftStick + case rightStick + + var id: Int { rawValue } + + var title: String { + switch self { + case .leftShoulder: return "L1" + case .rightShoulder: return "R1" + case .leftTrigger: return "L2" + case .rightTrigger: return "R2" + case .faceBottom: return "Cross" + case .faceLeft: return "Square" + case .faceRight: return "Circle" + case .faceTop: return "Triangle" + case .up: return "Up" + case .down: return "Down" + case .left: return "Left" + case .right: return "Right" + case .start: return "Start" + case .select: return "Select" + case .leftStick: return "L3" + case .rightStick: return "R3" + } + } + + var padButton: ARMSX2PadButton { + switch self { + case .leftShoulder: return .L1 + case .rightShoulder: return .R1 + case .leftTrigger: return .L2 + case .rightTrigger: return .R2 + case .faceBottom: return .cross + case .faceLeft: return .square + case .faceRight: return .circle + case .faceTop: return .triangle + case .up: return .up + case .down: return .down + case .left: return .left + case .right: return .right + case .start: return .start + case .select: return .select + case .leftStick: return .L3 + case .rightStick: return .R3 + } + } +} + +enum VirtualPadThumbstickSide { + case left + case right +} + +enum DynamicCrosshairType: Int, CaseIterable, Identifiable { + case classic = 0 + case dot = 1 + case circle = 2 + case circleDot = 3 + case cross = 4 + case chevron = 5 + case brackets = 6 + case diamond = 7 + case shotgun = 8 + case sniper = 9 + case tactical = 10 + case burst = 11 + case fourBoxes = 12 + case triad = 13 + case reactiveDot = 14 + + var id: Int { rawValue } + + static let allCases: [DynamicCrosshairType] = [ + .fourBoxes, + .triad, + .reactiveDot, + .classic, + .dot, + .circle, + .circleDot, + .cross, + .chevron, + .brackets, + .diamond, + .shotgun, + .sniper, + .tactical, + .burst + ] + + var title: String { + switch self { + case .classic: return "Classic" + case .dot: return "Dot" + case .circle: return "Circle" + case .circleDot: return "Circle + Dot" + case .cross: return "Full Cross" + case .chevron: return "Chevron" + case .brackets: return "Corner Brackets" + case .diamond: return "Diamond" + case .shotgun: return "Shotgun" + case .sniper: return "Sniper Scope" + case .tactical: return "Tactical" + case .burst: return "Eight-Point Burst" + case .fourBoxes: return "Four-Box Reactive" + case .triad: return "Three-Line Triangle" + case .reactiveDot: return "Reactive Dot" + } + } +} + +enum DynamicCrosshairAnimation: Int, CaseIterable, Identifiable { + case reactive = 0 + case pulse + case expand + case rotate + case recoil + case orbit + case focus + case wave + case directional + case elastic + case parallax + case velocity + case stabilizer + case snap + case drift + case tilt + case bloom + case directionLock + + var id: Int { rawValue } + + var title: String { + switch self { + case .reactive: return "Reactive" + case .pulse: return "Pulse" + case .expand: return "Expand" + case .rotate: return "Rotate" + case .recoil: return "Recoil" + case .orbit: return "Orbit" + case .focus: return "Focus" + case .wave: return "Wave" + case .directional: return "Directional" + case .elastic: return "Elastic" + case .parallax: return "Parallax" + case .velocity: return "Velocity" + case .stabilizer: return "Stabilizer" + case .snap: return "Snap" + case .drift: return "Drift" + case .tilt: return "Tilt" + case .bloom: return "Bloom" + case .directionLock: return "Direction Lock" + } + } +} + +@MainActor +@Observable +final class DynamicThumbstickSettings { + static let shared = DynamicThumbstickSettings() + private static let section = "ARMSX2iOS/DynamicThumbsticks" + + var legacyThumbsticks: Bool { didSet { setBool("LegacyThumbsticks", legacyThumbsticks) } } + var dynamicThumbsticks: Bool { didSet { setBool("DynamicThumbsticks", dynamicThumbsticks) } } + var swipeCamera: Bool { didSet { setBool("SwipeCamera", swipeCamera) } } + var gyroscopeCamera: Bool { didSet { setBool("GyroscopeCamera", gyroscopeCamera) } } + + var movementSensitivity: Double { didSet { setDouble("MovementSensitivity", movementSensitivity) } } + var lookSensitivity: Double { didSet { setDouble("LookSensitivity", lookSensitivity) } } + var swipeSensitivity: Double { didSet { setDouble("SwipeSensitivity", swipeSensitivity) } } + var swipeHorizontalSensitivity: Double { + didSet { setDouble("SwipeHorizontalSensitivity", swipeHorizontalSensitivity) } + } + var swipeVerticalSensitivity: Double { + didSet { setDouble("SwipeVerticalSensitivity", swipeVerticalSensitivity) } + } + var swipeSensitivityWhileAimingEnabled: Bool { + didSet { setBool("SwipeSensitivityWhileAimingEnabled", swipeSensitivityWhileAimingEnabled) } + } + var swipeSensitivityWhileAiming: Double { + didSet { setDouble("SwipeSensitivityWhileAiming", swipeSensitivityWhileAiming) } + } + var swipeHorizontalSensitivityWhileAiming: Double { + didSet { setDouble("SwipeHorizontalSensitivityWhileAiming", swipeHorizontalSensitivityWhileAiming) } + } + var swipeVerticalSensitivityWhileAiming: Double { + didSet { setDouble("SwipeVerticalSensitivityWhileAiming", swipeVerticalSensitivityWhileAiming) } + } + var swipeSensitivityWhileNotAimingEnabled: Bool { + didSet { setBool("SwipeSensitivityWhileNotAimingEnabled", swipeSensitivityWhileNotAimingEnabled) } + } + var swipeSensitivityWhileNotAiming: Double { + didSet { setDouble("SwipeSensitivityWhileNotAiming", swipeSensitivityWhileNotAiming) } + } + var swipeHorizontalSensitivityWhileNotAiming: Double { + didSet { setDouble("SwipeHorizontalSensitivityWhileNotAiming", swipeHorizontalSensitivityWhileNotAiming) } + } + var swipeVerticalSensitivityWhileNotAiming: Double { + didSet { setDouble("SwipeVerticalSensitivityWhileNotAiming", swipeVerticalSensitivityWhileNotAiming) } + } + var gyroSensitivity: Double { didSet { setDouble("GyroSensitivity", gyroSensitivity) } } + var gyroAcceleration: Double { didSet { setDouble("GyroAcceleration", gyroAcceleration) } } + var gyroSmoothing: Double { didSet { setDouble("GyroSmoothing", gyroSmoothing) } } + var gyroDeadZone: Double { didSet { setDouble("GyroDeadZone", gyroDeadZone) } } + var gyroMaximumRate: Double { didSet { setDouble("GyroMaximumRate", gyroMaximumRate) } } + var invertGyroHorizontal: Bool { didSet { setBool("InvertGyroHorizontal", invertGyroHorizontal) } } + var invertGyroVertical: Bool { didSet { setBool("InvertGyroVertical", invertGyroVertical) } } + + var thumbstickRadius: Double { didSet { setDouble("ThumbstickRadius", thumbstickRadius) } } + var deadZone: Double { didSet { setDouble("DeadZone", deadZone) } } + var thumbstickOpacity: Double { didSet { setDouble("ThumbstickOpacity", thumbstickOpacity) } } + var baseOpacity: Double { didSet { setDouble("BaseOpacity", baseOpacity) } } + var trailOpacity: Double { didSet { setDouble("TrailOpacity", trailOpacity) } } + var activationHaptics: Bool { didSet { setBool("ActivationHaptics", activationHaptics) } } + + var leftThumbstickActionsEnabled: Bool { didSet { setBool("LeftThumbstickActionsEnabled", leftThumbstickActionsEnabled) } } + var rightThumbstickActionsEnabled: Bool { didSet { setBool("DynamicThumbstickActionsEnabled", rightThumbstickActionsEnabled) } } + var holdAimWhileSwipe: Bool { didSet { setBool("HoldAimWhileSwipe", holdAimWhileSwipe) } } + var doubleTapToHoldAim: Bool { didSet { setBool("DoubleTapToHoldAim", doubleTapToHoldAim) } } + var tapToFire: Bool { didSet { setBool("TapToFire", tapToFire) } } + var rapidTapFireEnabled: Bool { didSet { setBool("RapidTapFireEnabled", rapidTapFireEnabled) } } + var releaseFireWhenTouchEnds: Bool { didSet { setBool("ReleaseFireWhenTouchEnds", releaseFireWhenTouchEnds) } } + var extendFireWhileDragging: Bool { didSet { setBool("ExtendFireWhileDragging", extendFireWhileDragging) } } + var aimReleaseDelay: Double { didSet { setDouble("AimReleaseDelay", aimReleaseDelay) } } + var doubleTapWindow: Double { didSet { setDouble("DoubleTapWindow", doubleTapWindow) } } + var tapMaximumDuration: Double { didSet { setDouble("TapMaximumDuration", tapMaximumDuration) } } + var tapTravelTolerance: Double { didSet { setDouble("TapTravelTolerance", tapTravelTolerance) } } + var rapidTapWindow: Double { didSet { setDouble("RapidTapWindow", rapidTapWindow) } } + var rapidTapActivationCount: Int { didSet { setInt("RapidTapActivationCount", rapidTapActivationCount) } } + var fireReleaseDelay: Double { didSet { setDouble("FireReleaseDelay", fireReleaseDelay) } } + var automaticFireInterval: Double { didSet { setDouble("AutomaticFireInterval", automaticFireInterval) } } + var dynamicCrosshairEnabled: Bool { didSet { setBool("DynamicCrosshairEnabled", dynamicCrosshairEnabled) } } + var dynamicCrosshairSize: Double { didSet { setDouble("DynamicCrosshairSize", dynamicCrosshairSize) } } + var dynamicCrosshairOpacity: Double { + didSet { setDouble("DynamicCrosshairOpacity", dynamicCrosshairOpacity) } + } + var dynamicCrosshairType: DynamicCrosshairType { + didSet { setInt("DynamicCrosshairType", dynamicCrosshairType.rawValue) } + } + var dynamicCrosshairAnimation: DynamicCrosshairAnimation { + didSet { setInt("DynamicCrosshairAnimation", dynamicCrosshairAnimation.rawValue) } + } + var leftAimButton: VirtualPadActionButton { didSet { setInt("LeftAimButton", leftAimButton.rawValue) } } + var leftFireButton: VirtualPadActionButton { didSet { setInt("LeftFireButton", leftFireButton.rawValue) } } + var leftHoldFireButton: VirtualPadActionButton { didSet { setInt("LeftHoldFireButton", leftHoldFireButton.rawValue) } } + var rightAimButton: VirtualPadActionButton { didSet { setInt("AimButton", rightAimButton.rawValue) } } + var rightFireButton: VirtualPadActionButton { didSet { setInt("FireButton", rightFireButton.rawValue) } } + var rightHoldFireButton: VirtualPadActionButton { didSet { setInt("HoldFireButton", rightHoldFireButton.rawValue) } } + + private init() { + legacyThumbsticks = Self.bool("LegacyThumbsticks", default: true) + dynamicThumbsticks = Self.bool("DynamicThumbsticks", default: false) + swipeCamera = Self.bool("SwipeCamera", default: false) + gyroscopeCamera = Self.bool("GyroscopeCamera", default: false) + movementSensitivity = Self.double("MovementSensitivity", default: 1.0) + lookSensitivity = Self.double("LookSensitivity", default: 1.0) + let storedSwipeSensitivity = Self.double("SwipeSensitivity", default: 0.28) + swipeSensitivity = storedSwipeSensitivity + swipeHorizontalSensitivity = Self.double("SwipeHorizontalSensitivity", default: 1) + swipeVerticalSensitivity = Self.double("SwipeVerticalSensitivity", default: 1) + swipeSensitivityWhileAimingEnabled = Self.bool("SwipeSensitivityWhileAimingEnabled", default: true) + swipeSensitivityWhileAiming = Self.double("SwipeSensitivityWhileAiming", default: 0.28) + swipeHorizontalSensitivityWhileAiming = Self.double("SwipeHorizontalSensitivityWhileAiming", default: 1) + swipeVerticalSensitivityWhileAiming = Self.double("SwipeVerticalSensitivityWhileAiming", default: 1) + swipeSensitivityWhileNotAimingEnabled = Self.bool("SwipeSensitivityWhileNotAimingEnabled", default: true) + swipeSensitivityWhileNotAiming = Self.double("SwipeSensitivityWhileNotAiming", default: 0.46) + swipeHorizontalSensitivityWhileNotAiming = Self.double("SwipeHorizontalSensitivityWhileNotAiming", default: 1) + swipeVerticalSensitivityWhileNotAiming = Self.double("SwipeVerticalSensitivityWhileNotAiming", default: 1) + gyroSensitivity = Self.double("GyroSensitivity", default: 1.5) + gyroAcceleration = Self.double("GyroAcceleration", default: 0.35) + gyroSmoothing = Self.double("GyroSmoothing", default: 0.72) + gyroDeadZone = Self.double("GyroDeadZone", default: 0.03) + gyroMaximumRate = Self.double("GyroMaximumRate", default: 6.0) + invertGyroHorizontal = Self.bool("InvertGyroHorizontal", default: false) + invertGyroVertical = Self.bool("InvertGyroVertical", default: false) + thumbstickRadius = Self.double("ThumbstickRadius", default: 52) + deadZone = Self.double("DeadZone", default: 0.08) + thumbstickOpacity = Self.double("ThumbstickOpacity", default: 0.72) + baseOpacity = Self.double("BaseOpacity", default: 0.20) + trailOpacity = Self.double("TrailOpacity", default: 0.10) + activationHaptics = Self.bool("ActivationHaptics", default: true) + leftThumbstickActionsEnabled = Self.bool("LeftThumbstickActionsEnabled", default: false) + rightThumbstickActionsEnabled = Self.bool("DynamicThumbstickActionsEnabled", default: false) + holdAimWhileSwipe = Self.bool("HoldAimWhileSwipe", default: false) + doubleTapToHoldAim = Self.bool("DoubleTapToHoldAim", default: false) + tapToFire = Self.bool("TapToFire", default: true) + rapidTapFireEnabled = Self.bool("RapidTapFireEnabled", default: true) + releaseFireWhenTouchEnds = Self.bool("ReleaseFireWhenTouchEnds", default: true) + extendFireWhileDragging = Self.bool("ExtendFireWhileDragging", default: true) + aimReleaseDelay = Self.double("AimReleaseDelay", default: 1.25) + doubleTapWindow = Self.double("DoubleTapWindow", default: 0.28) + tapMaximumDuration = Self.double("TapMaximumDuration", default: 0.18) + tapTravelTolerance = Self.double("TapTravelTolerance", default: 12) + rapidTapWindow = Self.double("RapidTapWindow", default: 0.28) + rapidTapActivationCount = Self.int("RapidTapActivationCount", default: 2) + fireReleaseDelay = Self.double("FireReleaseDelay", default: 0) + automaticFireInterval = Self.double("AutomaticFireInterval", default: 0.12) + dynamicCrosshairEnabled = Self.bool("DynamicCrosshairEnabled", default: false) + dynamicCrosshairSize = Self.double("DynamicCrosshairSize", default: 32) + dynamicCrosshairOpacity = Self.double("DynamicCrosshairOpacity", default: 0.70) + dynamicCrosshairType = DynamicCrosshairType( + rawValue: Self.int("DynamicCrosshairType", default: DynamicCrosshairType.fourBoxes.rawValue) + ) ?? .fourBoxes + dynamicCrosshairAnimation = DynamicCrosshairAnimation( + rawValue: Self.int("DynamicCrosshairAnimation", default: DynamicCrosshairAnimation.reactive.rawValue) + ) ?? .reactive + leftAimButton = Self.actionButton("LeftAimButton", default: .rightShoulder) + leftFireButton = Self.actionButton("LeftFireButton", default: .faceLeft) + leftHoldFireButton = Self.actionButton("LeftHoldFireButton", default: .faceLeft) + rightAimButton = Self.actionButton("AimButton", default: .rightShoulder) + rightFireButton = Self.actionButton("FireButton", default: .faceLeft) + rightHoldFireButton = Self.actionButton("HoldFireButton", default: .faceLeft) + + if legacyThumbsticks == dynamicThumbsticks { + legacyThumbsticks = true + dynamicThumbsticks = false + } + if holdAimWhileSwipe && doubleTapToHoldAim { + doubleTapToHoldAim = false + setBool("DoubleTapToHoldAim", false) + } + } + + func setLegacyThumbsticks(_ enabled: Bool) { + if enabled { + dynamicThumbsticks = false + legacyThumbsticks = true + } else { + legacyThumbsticks = false + dynamicThumbsticks = true + } + } + + func setDynamicThumbsticks(_ enabled: Bool) { + if enabled { + legacyThumbsticks = false + dynamicThumbsticks = true + } else { + dynamicThumbsticks = false + legacyThumbsticks = true + } + } + + func setHoldAimWhileSwipe(_ enabled: Bool) { + holdAimWhileSwipe = enabled + if enabled { + doubleTapToHoldAim = false + } + } + + func setDoubleTapToHoldAim(_ enabled: Bool) { + doubleTapToHoldAim = enabled + if enabled { + holdAimWhileSwipe = false + } + } + + func restoreDefaults() { + legacyThumbsticks = true + dynamicThumbsticks = false + swipeCamera = false + gyroscopeCamera = false + movementSensitivity = 1 + lookSensitivity = 1 + swipeSensitivity = 0.28 + swipeHorizontalSensitivity = 1 + swipeVerticalSensitivity = 1 + swipeSensitivityWhileAimingEnabled = true + swipeSensitivityWhileAiming = 0.28 + swipeHorizontalSensitivityWhileAiming = 1 + swipeVerticalSensitivityWhileAiming = 1 + swipeSensitivityWhileNotAimingEnabled = true + swipeSensitivityWhileNotAiming = 0.46 + swipeHorizontalSensitivityWhileNotAiming = 1 + swipeVerticalSensitivityWhileNotAiming = 1 + gyroSensitivity = 1.5 + gyroAcceleration = 0.35 + gyroSmoothing = 0.72 + gyroDeadZone = 0.03 + gyroMaximumRate = 6 + invertGyroHorizontal = false + invertGyroVertical = false + thumbstickRadius = 52 + deadZone = 0.08 + thumbstickOpacity = 0.72 + baseOpacity = 0.20 + trailOpacity = 0.10 + activationHaptics = true + leftThumbstickActionsEnabled = false + rightThumbstickActionsEnabled = false + holdAimWhileSwipe = false + doubleTapToHoldAim = false + tapToFire = true + rapidTapFireEnabled = true + releaseFireWhenTouchEnds = true + extendFireWhileDragging = true + aimReleaseDelay = 1.25 + doubleTapWindow = 0.28 + tapMaximumDuration = 0.18 + tapTravelTolerance = 12 + rapidTapWindow = 0.28 + rapidTapActivationCount = 2 + fireReleaseDelay = 0 + automaticFireInterval = 0.12 + dynamicCrosshairEnabled = false + dynamicCrosshairSize = 32 + dynamicCrosshairOpacity = 0.70 + dynamicCrosshairType = .fourBoxes + dynamicCrosshairAnimation = .reactive + leftAimButton = .rightShoulder + leftFireButton = .faceLeft + leftHoldFireButton = .faceLeft + rightAimButton = .rightShoulder + rightFireButton = .faceLeft + rightHoldFireButton = .faceLeft + } + + func aimButton(for side: VirtualPadThumbstickSide) -> VirtualPadActionButton { + side == .left ? leftAimButton : rightAimButton + } + + func fireButton(for side: VirtualPadThumbstickSide) -> VirtualPadActionButton { + side == .left ? leftFireButton : rightFireButton + } + + func holdFireButton(for side: VirtualPadThumbstickSide) -> VirtualPadActionButton { + side == .left ? leftHoldFireButton : rightHoldFireButton + } + + func effectiveSwipeSensitivity(isAiming: Bool) -> (horizontal: Double, vertical: Double) { + if isAiming, swipeSensitivityWhileAimingEnabled { + return ( + swipeSensitivityWhileAiming * swipeHorizontalSensitivityWhileAiming, + swipeSensitivityWhileAiming * swipeVerticalSensitivityWhileAiming + ) + } + if !isAiming, swipeSensitivityWhileNotAimingEnabled { + return ( + swipeSensitivityWhileNotAiming * swipeHorizontalSensitivityWhileNotAiming, + swipeSensitivityWhileNotAiming * swipeVerticalSensitivityWhileNotAiming + ) + } + return ( + swipeSensitivity * swipeHorizontalSensitivity, + swipeSensitivity * swipeVerticalSensitivity + ) + } + + private static func bool(_ key: String, default defaultValue: Bool) -> Bool { + ARMSX2Bridge.getINIBool(section, key: key, defaultValue: defaultValue) + } + + private static func double(_ key: String, default defaultValue: Double) -> Double { + Double(ARMSX2Bridge.getINIFloat(section, key: key, defaultValue: Float(defaultValue))) + } + + private static func int(_ key: String, default defaultValue: Int) -> Int { + Int(ARMSX2Bridge.getINIInt(section, key: key, defaultValue: Int32(defaultValue))) + } + + private static func actionButton(_ key: String, default defaultValue: VirtualPadActionButton) -> VirtualPadActionButton { + VirtualPadActionButton(rawValue: int(key, default: defaultValue.rawValue)) ?? defaultValue + } + + private func setBool(_ key: String, _ value: Bool) { + ARMSX2Bridge.setINIBool(Self.section, key: key, value: value) + } + + private func setDouble(_ key: String, _ value: Double) { + ARMSX2Bridge.setINIFloat(Self.section, key: key, value: Float(value)) + } + + private func setInt(_ key: String, _ value: Int) { + ARMSX2Bridge.setINIInt(Self.section, key: key, value: Int32(value)) + } +} diff --git a/platforms/ios/app/src/main/swift/Models/EmulatorBridge.swift b/platforms/ios/app/src/main/swift/Models/EmulatorBridge.swift index 6d6d4fff1a..23d7b7bc0d 100644 --- a/platforms/ios/app/src/main/swift/Models/EmulatorBridge.swift +++ b/platforms/ios/app/src/main/swift/Models/EmulatorBridge.swift @@ -90,6 +90,11 @@ final class EmulatorBridge: @unchecked Sendable { var biosName: String = "Unknown" var buildVersion: String = "" + @ObservationIgnored private var virtualRightTouchX: Float = 0 + @ObservationIgnored private var virtualRightTouchY: Float = 0 + @ObservationIgnored private var virtualRightMotionX: Float = 0 + @ObservationIgnored private var virtualRightMotionY: Float = 0 + private init() { biosName = ARMSX2Bridge.biosName() buildVersion = ARMSX2Bridge.buildVersion() @@ -109,14 +114,51 @@ final class EmulatorBridge: @unchecked Sendable { @MainActor func setLeftStick(x: Float, y: Float) { + let sensitivity = Float(DynamicThumbstickSettings.shared.movementSensitivity) + let output = Self.radiallyClamped(x: x * sensitivity, y: y * sensitivity) let inv = SettingsStore.shared.stickInversion(for: .left) - ARMSX2Bridge.setLeftStickX(inv.x ? -x : x, y: inv.y ? -y : y) + ARMSX2Bridge.setLeftStickX(inv.x ? -output.x : output.x, y: inv.y ? -output.y : output.y) } @MainActor func setRightStick(x: Float, y: Float) { + let sensitivity = Float(DynamicThumbstickSettings.shared.lookSensitivity) + virtualRightTouchX = x * sensitivity + virtualRightTouchY = y * sensitivity + applyVirtualRightStick() + } + + @MainActor + func setRightStickMotion(x: Float, y: Float) { + virtualRightMotionX = x + virtualRightMotionY = y + applyVirtualRightStick() + } + + @MainActor + func resetVirtualPadAnalogInput() { + virtualRightTouchX = 0 + virtualRightTouchY = 0 + virtualRightMotionX = 0 + virtualRightMotionY = 0 + ARMSX2Bridge.setLeftStickX(0, y: 0) + ARMSX2Bridge.setRightStickX(0, y: 0) + } + + @MainActor + private func applyVirtualRightStick() { + let output = Self.radiallyClamped( + x: virtualRightTouchX + virtualRightMotionX, + y: virtualRightTouchY + virtualRightMotionY + ) let inv = SettingsStore.shared.stickInversion(for: .right) - ARMSX2Bridge.setRightStickX(inv.x ? -x : x, y: inv.y ? -y : y) + ARMSX2Bridge.setRightStickX(inv.x ? -output.x : output.x, y: inv.y ? -output.y : output.y) + } + + private static func radiallyClamped(x: Float, y: Float) -> (x: Float, y: Float) { + let magnitude = hypotf(x, y) + guard magnitude > 1 else { return (x, y) } + return (x / magnitude, y / magnitude) } var isOsdVisible: Bool { diff --git a/platforms/ios/app/src/main/swift/Models/FrameTimeDynamicResolutionController.swift b/platforms/ios/app/src/main/swift/Models/FrameTimeDynamicResolutionController.swift index 3df883547b..58a5e80b2f 100644 --- a/platforms/ios/app/src/main/swift/Models/FrameTimeDynamicResolutionController.swift +++ b/platforms/ios/app/src/main/swift/Models/FrameTimeDynamicResolutionController.swift @@ -64,6 +64,7 @@ final class FrameTimeDynamicResolutionController { @ObservationIgnored private var lastWrittenValue: Float = 1.0 @ObservationIgnored private var lastWriteAt: Date = .distantPast @ObservationIgnored private var pollTimer: Timer? + @ObservationIgnored private var suspendedForEmulationOnlyMode = false private init() {} @@ -72,7 +73,12 @@ final class FrameTimeDynamicResolutionController { /// Called from `SettingsStore.adaptiveResolutionEnabled.didSet` on toggle, /// and after init once the persisted value has been read back. Idempotent. func setEnabled(_ newValue: Bool) { - guard newValue != enabled else { return } + guard newValue != enabled else { + if enabled && !suspendedForEmulationOnlyMode { + startTimer() + } + return + } enabled = newValue if enabled { // Capture the user's current UpscaleMultiplier as the max clamp. @@ -85,12 +91,32 @@ final class FrameTimeDynamicResolutionController { // of forcing a 500 ms wait after toggling. lastChangeAt = .distantPast lastWriteAt = .distantPast - startTimer() + if !suspendedForEmulationOnlyMode { + startTimer() + } } else { stopTimer() } } + /// Stops the optional 500 ms frame-history poll without changing the + /// persisted Dynamic Resolution preference or the emulator's essential + /// frame limiter/audio-video timing. + func suspendForEmulationOnlyMode() { + suspendedForEmulationOnlyMode = true + stopTimer() + } + + /// A new full gameplay presentation owns the optional monitor again. + /// Idempotent, so normal sessions pay no extra timer or observer cost. + func resumeAfterEmulationOnlyMode() { + guard suspendedForEmulationOnlyMode else { return } + suspendedForEmulationOnlyMode = false + if enabled { + startTimer() + } + } + private func startTimer() { guard pollTimer == nil else { return } // Timer + .common run-loop mode so the tick keeps firing during diff --git a/platforms/ios/app/src/main/swift/Models/GameEventHaptics.swift b/platforms/ios/app/src/main/swift/Models/GameEventHaptics.swift index 8956f477a5..33427ca44e 100644 --- a/platforms/ios/app/src/main/swift/Models/GameEventHaptics.swift +++ b/platforms/ios/app/src/main/swift/Models/GameEventHaptics.swift @@ -7,10 +7,11 @@ import UIKit @MainActor final class GameEventHaptics { static let shared = GameEventHaptics() - private let heavyGenerator = UIImpactFeedbackGenerator(style: .heavy) - private let mediumGenerator = UIImpactFeedbackGenerator(style: .medium) + private var heavyGenerator: UIImpactFeedbackGenerator? + private var mediumGenerator: UIImpactFeedbackGenerator? private var lastFire = Date.distantPast private var hapticsEnabled = true + private var releasedForEmulationOnlyMode = false private init() { hapticsEnabled = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "HapticFeedback", defaultValue: true) @@ -19,7 +20,7 @@ final class GameEventHaptics { /// Called from the bridge (game rumble path) when no rumble-capable controller is connected. /// Respects the user's HapticFeedback setting and throttles to avoid motor fatigue. func trigger(large: UInt16, small: UInt16) { - guard hapticsEnabled else { return } + guard hapticsEnabled, !releasedForEmulationOnlyMode else { return } guard large > 0 || small > 0 else { return } let now = Date() // Throttle to ~20 Hz so sustained rumble does not peg the haptic engine. @@ -28,10 +29,37 @@ final class GameEventHaptics { let intensity = max(Float(large), Float(small)) / Float(UInt16.max) // Heavy motor (large) dominates; fall back to medium for small-only rumble. - let generator = large > 0 ? heavyGenerator : mediumGenerator + let generator: UIImpactFeedbackGenerator + if large > 0 { + if let heavyGenerator { + generator = heavyGenerator + } else { + let created = UIImpactFeedbackGenerator(style: .heavy) + heavyGenerator = created + generator = created + } + } else if let mediumGenerator { + generator = mediumGenerator + } else { + let created = UIImpactFeedbackGenerator(style: .medium) + mediumGenerator = created + generator = created + } generator.impactOccurred(intensity: CGFloat(max(0.3, min(1.0, intensity)))) } + func prepareForGameplaySession() { + releasedForEmulationOnlyMode = false + refreshEnabled() + } + + func releaseForEmulationOnlyMode() { + releasedForEmulationOnlyMode = true + heavyGenerator = nil + mediumGenerator = nil + lastFire = .distantPast + } + /// Refresh the enabled flag when the user changes the HapticFeedback setting. func refreshEnabled() { hapticsEnabled = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "HapticFeedback", defaultValue: true) diff --git a/platforms/ios/app/src/main/swift/Models/InitialContentBootstrap.swift b/platforms/ios/app/src/main/swift/Models/InitialContentBootstrap.swift new file mode 100644 index 0000000000..7ee376d1ff --- /dev/null +++ b/platforms/ios/app/src/main/swift/Models/InitialContentBootstrap.swift @@ -0,0 +1,611 @@ +// InitialContentBootstrap.swift — Security-scoped ARMSX2 folder import +// SPDX-License-Identifier: GPL-3.0+ + +import Foundation + +@MainActor +@Observable +final class InitialContentBootstrap { + static let shared = InitialContentBootstrap() + nonisolated static let didChangeNotification = Notification.Name("ARMSX2iOSInitialContentDidChange") + + private static let bookmarkKey = "ARMSX2iOSExternalRootBookmark" + private static let displayNameKey = "ARMSX2iOSExternalRootDisplayName" + + private(set) var selectedFolderName: String? + private(set) var isRunning = false + + var hasSelectedFolder: Bool { + UserDefaults.standard.data(forKey: Self.bookmarkKey) != nil + } + + private init() { + selectedFolderName = UserDefaults.standard.string(forKey: Self.displayNameKey) + } + + /// Saves the access granted by UIDocumentPickerViewController and immediately + /// scans the selected ARMSX2 root for BIOS, GAMES, PRESETS, and SKINS. + func selectARMSX2Folder(_ url: URL) async -> String { + guard !isRunning else { + return "The selected ARMSX2 folder is already being scanned." + } + isRunning = true + defer { isRunning = false } + + // Security scope belongs to the exact URL returned by the picker. + // Do not standardize or reconstruct it before requesting access. + let selectedURL = url + let accessing = selectedURL.startAccessingSecurityScopedResource() + defer { + if accessing { + selectedURL.stopAccessingSecurityScopedResource() + } + } + + do { + let values = try selectedURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values.isDirectory == true else { + throw InitialContentAccessError.notDirectory + } + _ = try FileManager.default.contentsOfDirectory( + at: selectedURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + try saveBookmark(for: selectedURL) + let displayName = selectedURL.lastPathComponent.isEmpty + ? "ARMSX2" + : selectedURL.lastPathComponent + UserDefaults.standard.set(displayName, forKey: Self.displayNameKey) + selectedFolderName = displayName + + return await importContents(from: selectedURL) + } catch { + NSLog( + "[ARMSX2 iOS Folder] selection failed path=%@ securityScoped=%d error=%@", + selectedURL.path, + accessing ? 1 : 0, + error.localizedDescription + ) + return "ARMSX2 folder access failed: \(error.localizedDescription)" + } + } + + /// Resolves the saved bookmark and scans while its security scope is active. + func scanSelectedFolder() async -> String { + guard !isRunning else { + return "The selected ARMSX2 folder is already being scanned." + } + guard let bookmarkData = UserDefaults.standard.data(forKey: Self.bookmarkKey) else { + return "Select the ARMSX2 folder first." + } + + isRunning = true + defer { isRunning = false } + + do { + var isStale = false + // As with the picker URL, retain the resolved URL instance carrying + // the bookmark's scope instead of replacing it with a standardized URL. + let rootURL = try URL( + resolvingBookmarkData: bookmarkData, + options: bookmarkResolutionOptions, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + let accessing = rootURL.startAccessingSecurityScopedResource() + defer { + if accessing { + rootURL.stopAccessingSecurityScopedResource() + } + } + + let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values.isDirectory == true else { + throw InitialContentAccessError.notDirectory + } + _ = try FileManager.default.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + if isStale { + try saveBookmark(for: rootURL) + NSLog("[ARMSX2 iOS Folder] refreshed stale bookmark path=%@", rootURL.path) + } + + return await importContents(from: rootURL) + } catch { + NSLog("[ARMSX2 iOS Folder] bookmark scan failed error=%@", error.localizedDescription) + return "The saved ARMSX2 folder could not be opened. Select it again to renew access.\n\(error.localizedDescription)" + } + } + + func removeSelectedFolder() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: Self.bookmarkKey) + defaults.removeObject(forKey: Self.displayNameKey) + selectedFolderName = nil + NSLog("[ARMSX2 iOS Folder] removed saved folder access") + } + + private var bookmarkCreationOptions: URL.BookmarkCreationOptions { +#if targetEnvironment(macCatalyst) + [.withSecurityScope] +#else + // withSecurityScope is unavailable on iOS. A URL returned by the + // document picker carries its security scope in regular bookmark data. + // Keep the full bookmark payload for the most reliable provider lookup. + [] +#endif + } + + private var bookmarkResolutionOptions: URL.BookmarkResolutionOptions { +#if targetEnvironment(macCatalyst) + [.withSecurityScope, .withoutUI] +#else + [.withoutUI] +#endif + } + + private func saveBookmark(for url: URL) throws { + let bookmarkData = try url.bookmarkData( + options: bookmarkCreationOptions, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: Self.bookmarkKey) + } + + private func importContents(from rootDirectory: URL) async -> String { + let biosDirectory = URL( + fileURLWithPath: ARMSX2Bridge.biosDirectory(), + isDirectory: true + ) + let gameDirectory = URL( + fileURLWithPath: ARMSX2Bridge.isoDirectory(), + isDirectory: true + ) + + let preparation = await Task.detached(priority: .utility) { + InitialContentFileInstaller.prepare( + rootDirectory: rootDirectory, + biosDirectory: biosDirectory + ) + }.value + + configureDefaultBIOS() + let appliedPresets = applyPresetFiles(named: preparation.presetNames) + let skinImport = importSkinArchives(preparation.skinArchives) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) + + let gameImport = await Task.detached(priority: .utility) { + InitialContentFileInstaller.importGames( + preparation.gameFiles, + into: gameDirectory + ) + }.value + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) + let coverDownload = await downloadMissingCovers(for: gameImport.importedGameFiles) + if coverDownload.downloaded > 0 { + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) + } + + NSLog( + "[ARMSX2 iOS Folder] complete root=%@ BIOS candidates=%d copied=%d games discovered=%d imported=%d skipped=%d failed=%d covers=%d skins=%d skinSkipped=%d skinFailed=%d defaultSkin=%@ presets=%@", + rootDirectory.path, + preparation.biosCandidates, + preparation.biosCopied, + preparation.gameFiles.count, + gameImport.imported, + gameImport.skipped, + gameImport.failed, + coverDownload.downloaded, + skinImport.imported, + skinImport.skipped, + skinImport.failed, + skinImport.selectedDefaultName ?? "", + appliedPresets.joined(separator: ", ") + ) + + var summary = [ + "ARMSX2 folder scan complete.", + "BIOS: \(preparation.biosCopied) imported, \(preparation.biosCandidates) found.", + "Games: \(gameImport.imported) imported, \(gameImport.skipped) already present.", + "Covers: \(coverDownload.downloaded) downloaded for newly imported games.", + "Skins: \(skinImport.imported) imported, \(skinImport.skipped) already present." + ] + if gameImport.failed > 0 { + summary.append("Games that could not be imported: \(gameImport.failed).") + } + if !appliedPresets.isEmpty { + summary.append("Presets applied: \(appliedPresets.joined(separator: ", ")).") + } else { + summary.append("No matching built-in preset files were found.") + } + if skinImport.failed > 0 { + summary.append("Skins that could not be imported: \(skinImport.failed).") + } + if let selectedDefaultName = skinImport.selectedDefaultName { + summary.append("Default skin and layout: \(selectedDefaultName).") + } + return summary.joined(separator: "\n") + } + + private func configureDefaultBIOS() { + let validBIOSes = ARMSX2Bridge.availableBIOSInfos().filter(\.valid) + guard let firstValidBIOS = validBIOSes.first else { return } + + let currentDefault = ARMSX2Bridge.defaultBIOSName() + guard !validBIOSes.contains(where: { $0.fileName == currentDefault }) else { return } + ARMSX2Bridge.setDefaultBIOS(firstValidBIOS.fileName) + } + + private func applyPresetFiles(named normalizedNames: Set) -> [String] { + var applied: [String] = [] + + for preset in BuiltInSettingsPreset.allCases + where normalizedNames.contains(InitialContentFileInstaller.normalizedPresetName(preset.rawValue)) { + preset.apply() + applied.append(preset.rawValue) + } + + for preset in BuiltInDynamicControlPreset.allCases + where normalizedNames.contains(InitialContentFileInstaller.normalizedPresetName(preset.rawValue)) { + preset.apply() + applied.append(preset.rawValue) + } + return applied + } + + private func importSkinArchives(_ sourceURLs: [URL]) -> InitialSkinImportResult { + let skinLibrary = VPadSkinLibraryStore.shared + let layoutPresets = PadLayoutPresetStore.shared + var result = InitialSkinImportResult() + var defaultDescriptor: VPadSkinDescriptor? + + for sourceURL in sourceURLs.sorted(by: { + $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending + }) { + let originalName = sourceURL.lastPathComponent + let descriptor: VPadSkinDescriptor + + if let existing = skinLibrary.importedDescriptors.first(where: { + $0.originalImportName?.caseInsensitiveCompare(originalName) == .orderedSame + }) { + descriptor = existing + result.skipped += 1 + } else { + do { + let importResult = try skinLibrary.importSkinArchive( + from: sourceURL, + layoutPresets: layoutPresets + ) + descriptor = importResult.descriptor + result.imported += 1 + } catch { + result.failed += 1 + NSLog( + "[ARMSX2 iOS Folder] skin import failed source=%@ error=%@", + sourceURL.path, + error.localizedDescription + ) + continue + } + } + + if defaultDescriptor == nil, + sourceURL.deletingPathExtension().lastPathComponent.hasPrefix("1") { + defaultDescriptor = descriptor + } + } + + if let defaultDescriptor { + skinLibrary.selectSkin(id: defaultDescriptor.id) + SettingsStore.shared.virtualPadSkin = defaultDescriptor.virtualPadSkin + layoutPresets.globalPresetID = defaultDescriptor.linkedLayoutPresetID + result.selectedDefaultName = defaultDescriptor.displayName + } + return result + } + + private func downloadMissingCovers(for importedGameFiles: [URL]) async -> CoverDownloadSummary { + guard !importedGameFiles.isEmpty else { + return CoverDownloadSummary(downloaded: 0, skippedExisting: 0, failed: 0) + } + + let coverStore = CoverStore.shared + let targets = importedGameFiles.map { fileURL in + let name = fileURL.lastPathComponent + let metadata = ARMSX2Bridge.gameMetadata(forISO: name) + let existingCover = coverStore.coverURL( + forGameName: name, + gamePath: fileURL, + metadata: metadata + ) + return CoverGameInfo( + name: name, + fileURL: fileURL, + metadata: metadata, + hasCover: existingCover != nil + ) + } + return await coverStore.downloadMissingCovers(for: targets, showResult: false) + } +} + +private enum InitialContentAccessError: LocalizedError { + case notDirectory + + var errorDescription: String? { + switch self { + case .notDirectory: + return "The selected item is not a folder." + } + } +} + +private struct InitialContentPreparation: Sendable { + let biosCandidates: Int + let biosCopied: Int + let gameFiles: [URL] + let presetNames: Set + let skinArchives: [URL] +} + +private struct InitialGameImportResult: Sendable { + var imported = 0 + var skipped = 0 + var failed = 0 + var importedGameFiles: [URL] = [] +} + +private struct InitialSkinImportResult { + var imported = 0 + var skipped = 0 + var failed = 0 + var selectedDefaultName: String? +} + +private enum InitialContentFileInstaller { + private static let biosMaximumSize: UInt64 = 50 * 1024 * 1024 + private static let supportedGameExtensions = Set([ + "iso", "chd", "img", "bin", "cue", "mdf", "cso", "zso", "gz", "elf" + ]) + + static func prepare( + rootDirectory: URL, + biosDirectory: URL + ) -> InitialContentPreparation { + let fileManager = FileManager.default + var searchRoots = [rootDirectory] + searchRoots.append(contentsOf: existingDirectories( + named: "ARMSX2", + inside: rootDirectory, + fileManager: fileManager + )) + + let biosInputDirectories = inputDirectories( + named: "BIOS", + roots: searchRoots, + fileManager: fileManager + ) + let gameInputDirectories = inputDirectories( + named: "GAMES", + roots: searchRoots, + fileManager: fileManager + ) + let presetInputDirectories = inputDirectories( + named: "PRESETS", + roots: searchRoots, + fileManager: fileManager + ) + let skinInputDirectories = inputDirectories( + named: "SKINS", + roots: searchRoots, + fileManager: fileManager + ) + NSLog( + "[ARMSX2 iOS Folder] scan BIOS=%@ GAMES=%@ PRESETS=%@ SKINS=%@", + biosInputDirectories.map(\.path).joined(separator: ", "), + gameInputDirectories.map(\.path).joined(separator: ", "), + presetInputDirectories.map(\.path).joined(separator: ", "), + skinInputDirectories.map(\.path).joined(separator: ", ") + ) + + var biosCandidates = 0 + var biosCopied = 0 + var seenBIOSPaths = Set() + try? fileManager.createDirectory(at: biosDirectory, withIntermediateDirectories: true) + + for inputDirectory in biosInputDirectories { + for source in regularFiles(in: inputDirectory, fileManager: fileManager) + where source.pathExtension.caseInsensitiveCompare("bin") == .orderedSame || + source.pathExtension.caseInsensitiveCompare("rom") == .orderedSame { + let sourceKey = source.standardizedFileURL.path + guard seenBIOSPaths.insert(sourceKey).inserted, + fileSize(of: source, fileManager: fileManager) <= biosMaximumSize else { + continue + } + + biosCandidates += 1 + guard !sameDirectory(source.deletingLastPathComponent(), biosDirectory) else { + continue + } + + let destination = biosDirectory.appendingPathComponent(source.lastPathComponent) + guard !fileManager.fileExists(atPath: destination.path) else { continue } + do { + try ImportFileCopier.copy(from: source, to: destination) + biosCopied += 1 + } catch { + NSLog( + "[ARMSX2 iOS Folder] BIOS import failed source=%@ error=%@", + source.path, + error.localizedDescription + ) + } + } + } + + var gameFiles: [URL] = [] + var seenGamePaths = Set() + for inputDirectory in gameInputDirectories { + for source in regularFiles(in: inputDirectory, fileManager: fileManager) + where isSupportedGame(source, fileManager: fileManager) { + let sourceKey = source.standardizedFileURL.path + if seenGamePaths.insert(sourceKey).inserted { + gameFiles.append(source) + } + } + } + + var presetNames = Set() + var seenPresetPaths = Set() + for inputDirectory in presetInputDirectories { + for source in regularFiles(in: inputDirectory, fileManager: fileManager) + where source.pathExtension.caseInsensitiveCompare("ini") == .orderedSame { + let sourceKey = source.standardizedFileURL.path + guard seenPresetPaths.insert(sourceKey).inserted else { continue } + presetNames.insert( + normalizedPresetName(source.deletingPathExtension().lastPathComponent) + ) + } + } + + var skinArchives: [URL] = [] + var seenSkinPaths = Set() + for inputDirectory in skinInputDirectories { + for source in regularFiles(in: inputDirectory, fileManager: fileManager) + where source.pathExtension.caseInsensitiveCompare("zip") == .orderedSame { + let sourceKey = source.standardizedFileURL.path + if seenSkinPaths.insert(sourceKey).inserted { + skinArchives.append(source) + } + } + } + + return InitialContentPreparation( + biosCandidates: biosCandidates, + biosCopied: biosCopied, + gameFiles: gameFiles, + presetNames: presetNames, + skinArchives: skinArchives + ) + } + + static func importGames(_ sources: [URL], into destinationDirectory: URL) -> InitialGameImportResult { + let fileManager = FileManager.default + var result = InitialGameImportResult() + try? fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + + for source in sources { + let destination = destinationDirectory.appendingPathComponent(source.lastPathComponent) + if source.standardizedFileURL == destination.standardizedFileURL || + fileManager.fileExists(atPath: destination.path) { + result.skipped += 1 + continue + } + + do { + try ImportFileCopier.copy(from: source, to: destination) + result.imported += 1 + result.importedGameFiles.append(destination) + } catch { + result.failed += 1 + NSLog( + "[ARMSX2 iOS Folder] game import failed source=%@ error=%@", + source.path, + error.localizedDescription + ) + } + } + return result + } + + static func normalizedPresetName(_ name: String) -> String { + name + .trimmingCharacters(in: .whitespacesAndNewlines) + .precomposedStringWithCanonicalMapping + .lowercased(with: Locale(identifier: "en_US_POSIX")) + } + + private static func inputDirectories( + named name: String, + roots: [URL], + fileManager: FileManager + ) -> [URL] { + var directories: [URL] = [] + var seenPaths = Set() + for root in roots { + for directory in existingDirectories( + named: name, + inside: root, + fileManager: fileManager + ) { + let key = directory.standardizedFileURL.path + if seenPaths.insert(key).inserted { + directories.append(directory) + } + } + } + return directories + } + + private static func existingDirectories( + named name: String, + inside root: URL, + fileManager: FileManager + ) -> [URL] { + let children = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + return children?.filter { child in + guard child.lastPathComponent.caseInsensitiveCompare(name) == .orderedSame else { + return false + } + return (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } ?? [] + } + + private static func regularFiles(in directory: URL, fileManager: FileManager) -> [URL] { + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + return [] + } + + var files: [URL] = [] + for case let url as URL in enumerator { + if (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { + files.append(url) + } + } + return files + } + + private static func isSupportedGame(_ url: URL, fileManager: FileManager) -> Bool { + let ext = url.pathExtension.lowercased() + guard supportedGameExtensions.contains(ext) else { return false } + if ext == "bin" { + return fileSize(of: url, fileManager: fileManager) > biosMaximumSize + } + return true + } + + private static func fileSize(of url: URL, fileManager: FileManager) -> UInt64 { + let attributes = try? fileManager.attributesOfItem(atPath: url.path) + return attributes?[.size] as? UInt64 ?? 0 + } + + private static func sameDirectory(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.standardizedFileURL.path == rhs.standardizedFileURL.path + } +} diff --git a/platforms/ios/app/src/main/swift/Models/PatchStore.swift b/platforms/ios/app/src/main/swift/Models/PatchStore.swift index ffceb3a1b9..67c1d57100 100644 --- a/platforms/ios/app/src/main/swift/Models/PatchStore.swift +++ b/platforms/ios/app/src/main/swift/Models/PatchStore.swift @@ -74,6 +74,7 @@ final class PatchStore: @unchecked Sendable { private var currentSerial = "" private var currentCRC = "" private var currentTitle = "" + private var presentationGeneration: UInt64 = 0 var patchDatabaseURLTemplates: [String] { get { @@ -129,6 +130,25 @@ final class PatchStore: @unchecked Sendable { private init() {} + /// Invalidates presentation work and discards manager-only metadata. Installed files and + /// persisted preferences remain untouched; the native runtime separately unloads patches. + func releasePresentationResources() { + presentationGeneration &+= 1 + isoName = "" + launchContext = .library + identityState = .libraryAwaitingFirstLaunch + hasGameIdentity = false + canManageInstalledFiles = false + installed.removeAll(keepingCapacity: false) + lastMessage = nil + lastMessageKind = .information + showMessage = false + isDownloading = false + currentSerial = "" + currentCRC = "" + currentTitle = "" + } + // MARK: - Identity static func gameIdentityAvailable(forISO iso: String) -> Bool { @@ -594,6 +614,7 @@ final class PatchStore: @unchecked Sendable { // MARK: - Database download func downloadFromDatabase(forISO iso: String, asCheat: Bool) async { + let requestGeneration = presentationGeneration let crc = iso == isoName ? currentCRC : Self.formattedCRC(ARMSX2Bridge.gameSettings(forISO: iso)["crc"] as? String) guard !crc.isEmpty else { applyFeedback(identityState.guidance ?? "Database matching is unavailable for this game.", kind: .information) @@ -612,13 +633,18 @@ final class PatchStore: @unchecked Sendable { let title = iso == isoName ? currentTitle : (ARMSX2Bridge.gameMetadata(forISO: iso)["title"] ?? iso) isDownloading = true - defer { isDownloading = false } + defer { + if requestGeneration == presentationGeneration { + isDownloading = false + } + } var succeededSources: [String] = [] var notFoundCount = 0 var firstError: String? for template in templates { + guard !Task.isCancelled, requestGeneration == presentationGeneration else { return } guard let url = resolvedDatabaseURL(template: template, serial: serial, crc: crc, title: title) else { continue } @@ -629,6 +655,7 @@ final class PatchStore: @unchecked Sendable { request.timeoutInterval = 10 request.cachePolicy = .reloadIgnoringLocalCacheData let (data, response) = try await URLSession.shared.data(for: request) + guard !Task.isCancelled, requestGeneration == presentationGeneration else { return } guard let http = response as? HTTPURLResponse else { if firstError == nil { firstError = "Could not download from \(sourceName)." } continue @@ -653,6 +680,7 @@ final class PatchStore: @unchecked Sendable { if firstError == nil { firstError = "The file from \(sourceName) was not a valid patch." } continue } + guard !Task.isCancelled, requestGeneration == presentationGeneration else { return } let outcome = writePatch( text: text, forISO: iso, @@ -667,10 +695,13 @@ final class PatchStore: @unchecked Sendable { if firstError == nil { firstError = "\(sourceName): \(outcome.message)" } } } catch { + guard !Task.isCancelled, requestGeneration == presentationGeneration else { return } if firstError == nil { firstError = "Could not reach \(sourceName). Check your connection or URL." } } } + guard !Task.isCancelled, requestGeneration == presentationGeneration else { return } + let kindWord = asCheat ? "Cheat" : "Patch" if !succeededSources.isEmpty { // Refresh so newly installed entries show up without reopening the manager. diff --git a/platforms/ios/app/src/main/swift/Models/SettingsPresetCatalog.swift b/platforms/ios/app/src/main/swift/Models/SettingsPresetCatalog.swift new file mode 100644 index 0000000000..39ce8ed01e --- /dev/null +++ b/platforms/ios/app/src/main/swift/Models/SettingsPresetCatalog.swift @@ -0,0 +1,234 @@ +// SettingsPresetCatalog.swift — Built-in settings and Dynamic Control presets +// SPDX-License-Identifier: GPL-3.0+ + +import Foundation + +enum BuiltInSettingsPreset: String, CaseIterable, Identifiable { + case defaultPreset = "Default" + case ultraQuality = "Ultra Quality" + case highQuality = "High Quality" + case highQuality30FPS = "High Quality 30 FPS" + case performance = "Performance" + case ultraPerformance = "Ultra Performance" + + var id: String { rawValue } + + var summary: String { + switch self { + case .defaultPreset: + return "Restores the graphics and emulation options managed by quality presets to the ARMSX2 defaults." + case .ultraQuality: + return "Uses 2x internal resolution with FXAA and CAS Sharpening for maximum image quality." + case .highQuality: + return "Uses native internal resolution with FXAA and CAS Sharpening." + case .highQuality30FPS: + return "Uses the High Quality configuration and enables OPH Flag Hack for demanding 30 FPS games." + case .performance: + return "Uses native internal resolution without FXAA or CAS Sharpening to reduce GPU load." + case .ultraPerformance: + return "Uses the Performance configuration with Emulation-Only Mode. Intended for low-end devices." + } + } + + var detail: String { + switch self { + case .defaultPreset: + return "Default disables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, OPH Flag Hack, Emulation-Only Mode, and backgrounds in Help and Settings; restores Internal Resolution to 1x Native, Aspect Ratio to Auto, and Queue Size to 8; and selects the White Colored Virtual Pad skin." + case .ultraQuality: + return "Ultra Quality enables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, and backgrounds in Help and Settings; sets Internal Resolution to 2x (1024x896), Aspect Ratio to Stretch to Window, and Queue Size to 2. OPH Flag Hack and Emulation-Only Mode are disabled. The selected Virtual Pad skin is preserved." + case .highQuality: + return "High Quality uses the Ultra Quality settings, including backgrounds in Help and Settings, with Internal Resolution set to 1x Native (512x448). The selected Virtual Pad skin is preserved." + case .highQuality30FPS: + return "High Quality 30 FPS uses the High Quality graphics and emulation settings, enables OPH Flag Hack, and disables backgrounds in Help and Settings. It does not change the frame-limiter target or the selected Virtual Pad skin." + case .performance: + return "Performance uses the High Quality settings with FXAA, CAS Sharpening, and backgrounds in Help and Settings disabled. The selected Virtual Pad skin is preserved." + case .ultraPerformance: + return "Ultra Performance uses the Performance settings, disables backgrounds in Help and Settings, and enables Emulation-Only Mode. This preset is intended for low-end devices and requires an external controller when Virtual Control Layout unloading is enabled. The selected Virtual Pad skin is preserved." + } + } + + var preservesVirtualPadSkin: Bool { + self != .defaultPreset + } + + @MainActor + func isActive( + settings: SettingsStore = .shared, + skinLibrary: VPadSkinLibraryStore = .shared + ) -> Bool { + let configuration = self.configuration + let settingsMatch = settings.fastBoot == configuration.fastBoot && + settings.enableCheats == configuration.enableCheats && + settings.enableWidescreenPatches == configuration.enableWidescreenPatches && + settings.fastCDVD == configuration.fastCDVD && + settings.fxaa == configuration.fxaa && + (settings.casMode > 0) == configuration.casSharpening && + settings.aspectRatio == configuration.aspectRatio && + settings.vsyncQueueSize == configuration.vsyncQueueSize && + settings.upscaleMultiplier == configuration.upscaleMultiplier && + settings.gameFixEnabled("OPHFlagHack") == configuration.ophFlagHack && + settings.emulationOnlyModeEnabled == configuration.emulationOnlyMode && + settings.backgroundEnabledInHelp == configuration.showBackgroundInHelpAndSettings && + settings.backgroundEnabledInSettings == configuration.showBackgroundInHelpAndSettings + guard settingsMatch else { return false } + guard self == .defaultPreset else { return true } + return skinLibrary.selectedSkinID == VirtualPadSkin.armsx2Refresh.descriptorID && + settings.virtualPadSkin == .armsx2Refresh + } + + @MainActor + func apply( + settings: SettingsStore = .shared, + skinLibrary: VPadSkinLibraryStore = .shared + ) { + let configuration = self.configuration + settings.fastBoot = configuration.fastBoot + settings.enableCheats = configuration.enableCheats + settings.enableWidescreenPatches = configuration.enableWidescreenPatches + settings.fastCDVD = configuration.fastCDVD + settings.fxaa = configuration.fxaa + settings.casMode = configuration.casSharpening ? 1 : 0 + settings.aspectRatio = configuration.aspectRatio + settings.vsyncQueueSize = configuration.vsyncQueueSize + settings.upscaleMultiplier = configuration.upscaleMultiplier + settings.setGameFix("OPHFlagHack", configuration.ophFlagHack) + settings.emulationOnlyModeEnabled = configuration.emulationOnlyMode + settings.backgroundEnabledInHelp = configuration.showBackgroundInHelpAndSettings + settings.backgroundEnabledInSettings = configuration.showBackgroundInHelpAndSettings + if self == .defaultPreset { + skinLibrary.selectSkin(id: VirtualPadSkin.armsx2Refresh.descriptorID) + settings.virtualPadSkin = .armsx2Refresh + } + } + + private var configuration: Configuration { + switch self { + case .defaultPreset: + return Configuration( + fastBoot: false, + enableCheats: false, + enableWidescreenPatches: false, + fastCDVD: false, + fxaa: false, + casSharpening: false, + aspectRatio: 1, + vsyncQueueSize: 8, + upscaleMultiplier: 1, + ophFlagHack: false, + emulationOnlyMode: false + ) + case .ultraQuality: + var configuration = qualityConfiguration(upscaleMultiplier: 2) + configuration.showBackgroundInHelpAndSettings = true + return configuration + case .highQuality: + var configuration = qualityConfiguration(upscaleMultiplier: 1) + configuration.showBackgroundInHelpAndSettings = true + return configuration + case .highQuality30FPS: + var configuration = qualityConfiguration(upscaleMultiplier: 1) + configuration.ophFlagHack = true + return configuration + case .performance: + var configuration = qualityConfiguration(upscaleMultiplier: 1) + configuration.fxaa = false + configuration.casSharpening = false + return configuration + case .ultraPerformance: + var configuration = configurationForPerformance + configuration.emulationOnlyMode = true + return configuration + } + } + + private var configurationForPerformance: Configuration { + var configuration = qualityConfiguration(upscaleMultiplier: 1) + configuration.fxaa = false + configuration.casSharpening = false + return configuration + } + + private func qualityConfiguration(upscaleMultiplier: Float) -> Configuration { + Configuration( + fastBoot: true, + enableCheats: true, + enableWidescreenPatches: true, + fastCDVD: true, + fxaa: true, + casSharpening: true, + aspectRatio: 0, + vsyncQueueSize: 2, + upscaleMultiplier: upscaleMultiplier, + ophFlagHack: false, + emulationOnlyMode: false + ) + } + + private struct Configuration { + var fastBoot: Bool + var enableCheats: Bool + var enableWidescreenPatches: Bool + var fastCDVD: Bool + var fxaa: Bool + var casSharpening: Bool + var aspectRatio: Int + var vsyncQueueSize: Int + var upscaleMultiplier: Float + var ophFlagHack: Bool + var emulationOnlyMode: Bool + var showBackgroundInHelpAndSettings = false + } +} + +enum BuiltInDynamicControlPreset: String, CaseIterable, Identifiable { + case defaultPreset = "Default" + case mgs3 = "MGS 3" + + var id: String { rawValue } + + var summary: String { + switch self { + case .defaultPreset: + return "Restores the Dynamic Control switches managed by presets while preserving sensitivities and button assignments." + case .mgs3: + return "Enables Dynamic Thumbsticks, Swipe Camera, right-thumbstick actions, the aiming crosshair, and Double Tap to Hold Aim." + } + } + + @MainActor + func isActive(settings: DynamicThumbstickSettings = .shared) -> Bool { + switch self { + case .defaultPreset: + return settings.legacyThumbsticks && + !settings.dynamicThumbsticks && + !settings.swipeCamera && + !settings.rightThumbstickActionsEnabled && + !settings.dynamicCrosshairEnabled && + !settings.doubleTapToHoldAim + case .mgs3: + return settings.dynamicThumbsticks && + settings.swipeCamera && + settings.rightThumbstickActionsEnabled && + settings.dynamicCrosshairEnabled && + settings.doubleTapToHoldAim + } + } + + @MainActor + func apply(settings: DynamicThumbstickSettings = .shared) { + switch self { + case .defaultPreset: + settings.setLegacyThumbsticks(true) + settings.swipeCamera = false + settings.rightThumbstickActionsEnabled = false + settings.dynamicCrosshairEnabled = false + settings.setDoubleTapToHoldAim(false) + case .mgs3: + settings.setDynamicThumbsticks(true) + settings.swipeCamera = true + settings.rightThumbstickActionsEnabled = true + settings.dynamicCrosshairEnabled = true + settings.setDoubleTapToHoldAim(true) + } + } +} diff --git a/platforms/ios/app/src/main/swift/Models/SettingsPresetFile.swift b/platforms/ios/app/src/main/swift/Models/SettingsPresetFile.swift new file mode 100644 index 0000000000..fa7603437e --- /dev/null +++ b/platforms/ios/app/src/main/swift/Models/SettingsPresetFile.swift @@ -0,0 +1,251 @@ +// SettingsPresetFile.swift — Import/export for user settings presets +// SPDX-License-Identifier: GPL-3.0+ + +import Foundation + +struct SettingsPresetImportOutcome: Sendable { + let name: String + let appliedFieldCount: Int +} + +enum SettingsPresetFileError: LocalizedError { + case unreadableText + case unsupportedVersion(String) + case noSupportedSettings + case invalidValue(section: String, key: String, value: String) + + var errorDescription: String? { + switch self { + case .unreadableText: + return "The preset is not readable UTF-8 or ASCII text." + case .unsupportedVersion(let version): + return "This preset uses unsupported format version \(version)." + case .noSupportedSettings: + return "The file does not contain supported ARMSX2 preset settings." + case .invalidValue(let section, let key, let value): + return "Invalid value \"\(value)\" for [\(section)] \(key)." + } + } +} + +enum SettingsPresetFile { + static let formatVersion = 1 + + @MainActor + static func exportData( + name: String, + settings: SettingsStore = .shared + ) -> Data { + let safeName = name + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespaces) + let effectiveName = safeName.isEmpty ? "ARMSX2 Settings Preset" : safeName + let text = """ + ; ARMSX2 iOS Settings Preset + ; Unknown sections and keys are ignored by newer versions. + + [Preset] + FormatVersion=\(formatVersion) + Name=\(effectiveName) + + [Emulator] + FastBoot=\(encode(settings.fastBoot)) + EnablePNACHCheats=\(encode(settings.enableCheats)) + WidescreenPatches=\(encode(settings.enableWidescreenPatches)) + FastCDVD=\(encode(settings.fastCDVD)) + + [Graphics] + FXAA=\(encode(settings.fxaa)) + CASMode=\(settings.casMode) + CASSharpness=\(settings.casSharpness) + AspectRatio=\(SettingsStore.aspectRatioName(for: settings.aspectRatio)) + QueueSize=\(settings.vsyncQueueSize) + + [VirtualPad] + ButtonSkin=\(settings.virtualPadSkin.rawValue) + """ + return Data(text.utf8) + } + + @MainActor + static func importData( + _ data: Data, + fallbackName: String, + settings: SettingsStore = .shared, + skinLibrary: VPadSkinLibraryStore = .shared + ) throws -> SettingsPresetImportOutcome { + guard let text = String(data: data, encoding: .utf8) ?? + String(data: data, encoding: .ascii) else { + throw SettingsPresetFileError.unreadableText + } + + let ini = INIValues(text: text) + if let version = ini.value(section: "Preset", key: "FormatVersion"), + version != String(formatVersion) { + throw SettingsPresetFileError.unsupportedVersion(version) + } + + let values = try ParsedSettings(ini: ini) + guard values.fieldCount > 0 else { + throw SettingsPresetFileError.noSupportedSettings + } + values.apply(settings: settings, skinLibrary: skinLibrary) + + let declaredName = ini.value(section: "Preset", key: "Name")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let name = declaredName?.isEmpty == false ? declaredName! : fallbackName + return SettingsPresetImportOutcome(name: name, appliedFieldCount: values.fieldCount) + } + + private static func encode(_ value: Bool) -> String { + value ? "true" : "false" + } +} + +private struct ParsedSettings { + var fastBoot: Bool? + var enableCheats: Bool? + var widescreenPatches: Bool? + var fastCDVD: Bool? + var fxaa: Bool? + var casMode: Int? + var casSharpness: Int? + var aspectRatio: Int? + var queueSize: Int? + var buttonSkin: VirtualPadSkin? + + var fieldCount: Int { + [ + fastBoot != nil, + enableCheats != nil, + widescreenPatches != nil, + fastCDVD != nil, + fxaa != nil, + casMode != nil, + casSharpness != nil, + aspectRatio != nil, + queueSize != nil, + buttonSkin != nil + ].filter { $0 }.count + } + + init(ini: INIValues) throws { + fastBoot = try ini.bool(section: "Emulator", key: "FastBoot") + enableCheats = try ini.bool(section: "Emulator", key: "EnablePNACHCheats") + widescreenPatches = try ini.bool(section: "Emulator", key: "WidescreenPatches") + fastCDVD = try ini.bool(section: "Emulator", key: "FastCDVD") + fxaa = try ini.bool(section: "Graphics", key: "FXAA") + casMode = try ini.int(section: "Graphics", key: "CASMode", range: 0...2) + casSharpness = try ini.int(section: "Graphics", key: "CASSharpness", range: 0...100) + queueSize = try ini.int(section: "Graphics", key: "QueueSize", range: 2...16) + + if let value = ini.value(section: "Graphics", key: "AspectRatio") { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + switch normalized.lowercased() { + case "stretch", "0": + aspectRatio = 0 + case "auto 4:3/3:2", "1": + aspectRatio = 1 + case "4:3", "2": + aspectRatio = 2 + case "16:9", "3": + aspectRatio = 3 + case "10:7", "4": + aspectRatio = 4 + default: + throw SettingsPresetFileError.invalidValue( + section: "Graphics", + key: "AspectRatio", + value: value + ) + } + } + + if let value = ini.value(section: "VirtualPad", key: "ButtonSkin") { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let rawValue = Int(trimmed), let skin = VirtualPadSkin(rawValue: rawValue) { + buttonSkin = skin + } else if let skin = VirtualPadSkin.allCases.first(where: { + $0.label.caseInsensitiveCompare(trimmed) == .orderedSame + }) { + buttonSkin = skin + } else { + throw SettingsPresetFileError.invalidValue( + section: "VirtualPad", + key: "ButtonSkin", + value: value + ) + } + } + } + + @MainActor + func apply(settings: SettingsStore, skinLibrary: VPadSkinLibraryStore) { + if let fastBoot { settings.fastBoot = fastBoot } + if let enableCheats { settings.enableCheats = enableCheats } + if let widescreenPatches { settings.enableWidescreenPatches = widescreenPatches } + if let fastCDVD { settings.fastCDVD = fastCDVD } + if let fxaa { settings.fxaa = fxaa } + if let casMode { settings.casMode = casMode } + if let casSharpness { settings.casSharpness = casSharpness } + if let aspectRatio { settings.aspectRatio = aspectRatio } + if let queueSize { settings.vsyncQueueSize = queueSize } + if let buttonSkin { + skinLibrary.selectSkin(id: buttonSkin.descriptorID) + settings.virtualPadSkin = buttonSkin + } + } +} + +private struct INIValues { + private var storage: [String: String] = [:] + + init(text: String) { + var section = "" + for rawLine in text.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix(";"), !line.hasPrefix("#") else { + continue + } + if line.hasPrefix("["), line.hasSuffix("]") { + section = String(line.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + continue + } + guard let separator = line.firstIndex(of: "=") else { continue } + let key = String(line[.. String? { + storage[Self.storageKey(section: section, key: key)] + } + + func bool(section: String, key: String) throws -> Bool? { + guard let value = value(section: section, key: key) else { return nil } + switch value.lowercased() { + case "true", "yes", "on", "1": + return true + case "false", "no", "off", "0": + return false + default: + throw SettingsPresetFileError.invalidValue(section: section, key: key, value: value) + } + } + + func int(section: String, key: String, range: ClosedRange) throws -> Int? { + guard let value = value(section: section, key: key) else { return nil } + guard let parsed = Int(value), range.contains(parsed) else { + throw SettingsPresetFileError.invalidValue(section: section, key: key, value: value) + } + return parsed + } + + private static func storageKey(section: String, key: String) -> String { + "\(section.lowercased()).\(key.lowercased())" + } +} diff --git a/platforms/ios/app/src/main/swift/Models/SettingsStore.swift b/platforms/ios/app/src/main/swift/Models/SettingsStore.swift index 2fa50eb214..1ed25aebc8 100644 --- a/platforms/ios/app/src/main/swift/Models/SettingsStore.swift +++ b/platforms/ios/app/src/main/swift/Models/SettingsStore.swift @@ -112,6 +112,8 @@ final class SettingsStore { static let textureOffsetRange = -4096...4096 static let skipDrawRange = 0...5000 static let defaultOsdPerformancePosition = 3 + static let emulationOnlyModeDelayRange = 0...15 + static let defaultEmulationOnlyModeDelaySeconds = 5 /// Manual EmuCore/Gamefixes toggles — see SettingsStore+GameFixes.swift. @@ -206,6 +208,133 @@ final class SettingsStore { ARMSX2Bridge.setINIBool("EmuCore/CPU/Recompiler", key: "EnableFastmem", value: fastmem) } } + let _emulationOnlyModeConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyMode", default: false, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyModeEnabled: Bool = false { didSet { + guard !(_emulationOnlyModeConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyModeConfig.writer( + _emulationOnlyModeConfig.section, + _emulationOnlyModeConfig.key, + emulationOnlyModeEnabled + ) + _emulationOnlyModeConfig.onSet?(emulationOnlyModeEnabled) + }} + let _emulationOnlyDisablePatchesConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisablePatches", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisablePatches: Bool = true { didSet { + guard !(_emulationOnlyDisablePatchesConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisablePatchesConfig.writer( + _emulationOnlyDisablePatchesConfig.section, + _emulationOnlyDisablePatchesConfig.key, + emulationOnlyDisablePatches + ) + _emulationOnlyDisablePatchesConfig.onSet?(emulationOnlyDisablePatches) + }} + // Discord presence is always released by Emulation-Only Mode. + let emulationOnlyDisableDiscordPresence = true + let _emulationOnlyDisablePINEConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisablePINE", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisablePINE: Bool = true { didSet { + guard !(_emulationOnlyDisablePINEConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisablePINEConfig.writer( + _emulationOnlyDisablePINEConfig.section, + _emulationOnlyDisablePINEConfig.key, + emulationOnlyDisablePINE) + }} + let _emulationOnlyDisableRetroAchievementsConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableRetroAchievements", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableRetroAchievements: Bool = true { didSet { + guard !(_emulationOnlyDisableRetroAchievementsConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableRetroAchievementsConfig.writer( + _emulationOnlyDisableRetroAchievementsConfig.section, + _emulationOnlyDisableRetroAchievementsConfig.key, + emulationOnlyDisableRetroAchievements) + }} + let _emulationOnlyDisableInputRecordingConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableInputRecording", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableInputRecording: Bool = true { didSet { + guard !(_emulationOnlyDisableInputRecordingConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableInputRecordingConfig.writer( + _emulationOnlyDisableInputRecordingConfig.section, + _emulationOnlyDisableInputRecordingConfig.key, + emulationOnlyDisableInputRecording) + }} + let _emulationOnlyDisableOSDConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableOSD", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableOSD: Bool = true { didSet { + guard !(_emulationOnlyDisableOSDConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableOSDConfig.writer( + _emulationOnlyDisableOSDConfig.section, + _emulationOnlyDisableOSDConfig.key, + emulationOnlyDisableOSD) + }} + let _emulationOnlyDisableFramePacingConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableFramePacing", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableFramePacing: Bool = true { didSet { + guard !(_emulationOnlyDisableFramePacingConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableFramePacingConfig.writer( + _emulationOnlyDisableFramePacingConfig.section, + _emulationOnlyDisableFramePacingConfig.key, + emulationOnlyDisableFramePacing) + }} + let _emulationOnlyDisableVirtualControlsConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableVirtualControls", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableVirtualControls: Bool = true { didSet { + guard !(_emulationOnlyDisableVirtualControlsConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableVirtualControlsConfig.writer( + _emulationOnlyDisableVirtualControlsConfig.section, + _emulationOnlyDisableVirtualControlsConfig.key, + emulationOnlyDisableVirtualControls) + }} + let _emulationOnlyDisableQuickMenuConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableQuickMenu", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyDisableQuickMenu: Bool = true { didSet { + guard !(_emulationOnlyDisableQuickMenuConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyDisableQuickMenuConfig.writer( + _emulationOnlyDisableQuickMenuConfig.section, + _emulationOnlyDisableQuickMenuConfig.key, + emulationOnlyDisableQuickMenu) + }} + let _emulationOnlyClearNetworkCacheConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyClearNetworkCache", default: true, + writer: ARMSX2Bridge.setINIBool) + var emulationOnlyClearNetworkCache: Bool = true { didSet { + guard !(_emulationOnlyClearNetworkCacheConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyClearNetworkCacheConfig.writer( + _emulationOnlyClearNetworkCacheConfig.section, + _emulationOnlyClearNetworkCacheConfig.key, + emulationOnlyClearNetworkCache) + }} + let _emulationOnlyModeDelayConfig = Setting( + section: "ARMSX2iOS/UI", key: "EmulationOnlyModeDelaySeconds", + default: SettingsStore.defaultEmulationOnlyModeDelaySeconds, + writer: { section, key, value in + ARMSX2Bridge.setINIInt( + section, + key: key, + value: Int32(SettingsStore.clamped(value, to: SettingsStore.emulationOnlyModeDelayRange))) + }) + var emulationOnlyModeDelaySeconds = SettingsStore.defaultEmulationOnlyModeDelaySeconds { didSet { + let clamped = Self.clamped(emulationOnlyModeDelaySeconds, to: Self.emulationOnlyModeDelayRange) + guard emulationOnlyModeDelaySeconds == clamped else { + emulationOnlyModeDelaySeconds = clamped + return + } + guard !(_emulationOnlyModeDelayConfig.suppressible && suppressINIWrites) else { return } + _emulationOnlyModeDelayConfig.writer( + _emulationOnlyModeDelayConfig.section, + _emulationOnlyModeDelayConfig.key, + emulationOnlyModeDelaySeconds) + }} // ── CPU Rounding & Clamping ── // FPU/VU rounding and clamping improve accuracy/compatibility for specific games. @@ -1678,6 +1807,21 @@ final class SettingsStore { vu1Recompiler = ARMSX2Bridge.getINIBool("EmuCore/CPU/Recompiler", key: "EnableVU1", defaultValue: true) fastBoot = Self.loadedFastBoot() fastmem = ARMSX2Bridge.getINIBool("EmuCore/CPU/Recompiler", key: "EnableFastmem", defaultValue: true) + emulationOnlyModeEnabled = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyMode", defaultValue: false) + emulationOnlyDisablePatches = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisablePatches", defaultValue: true) + emulationOnlyDisablePINE = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisablePINE", defaultValue: true) + emulationOnlyDisableRetroAchievements = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableRetroAchievements", defaultValue: true) + emulationOnlyDisableInputRecording = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableInputRecording", defaultValue: true) + emulationOnlyDisableOSD = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableOSD", defaultValue: true) + emulationOnlyDisableFramePacing = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableFramePacing", defaultValue: true) + emulationOnlyDisableVirtualControls = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableVirtualControls", defaultValue: true) + emulationOnlyDisableQuickMenu = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableQuickMenu", defaultValue: true) + emulationOnlyClearNetworkCache = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyClearNetworkCache", defaultValue: true) + emulationOnlyModeDelaySeconds = Self.clamped( + Int(ARMSX2Bridge.getINIInt( + "ARMSX2iOS/UI", key: "EmulationOnlyModeDelaySeconds", + defaultValue: Int32(Self.defaultEmulationOnlyModeDelaySeconds))), + to: Self.emulationOnlyModeDelayRange) // CPU rounding & clamping eeFpuRoundMode = Self.clampedRoundMode(Int(ARMSX2Bridge.getINIInt("EmuCore/CPU", key: "FPU.Roundmode", defaultValue: 3))) vu0RoundMode = Self.clampedRoundMode(Int(ARMSX2Bridge.getINIInt("EmuCore/CPU", key: "VU0.Roundmode", defaultValue: 3))) @@ -1908,6 +2052,21 @@ final class SettingsStore { vu1Recompiler = ARMSX2Bridge.getINIBool("EmuCore/CPU/Recompiler", key: "EnableVU1", defaultValue: true) fastBoot = Self.loadedFastBoot() fastmem = ARMSX2Bridge.getINIBool("EmuCore/CPU/Recompiler", key: "EnableFastmem", defaultValue: true) + emulationOnlyModeEnabled = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyMode", defaultValue: false) + emulationOnlyDisablePatches = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisablePatches", defaultValue: true) + emulationOnlyDisablePINE = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisablePINE", defaultValue: true) + emulationOnlyDisableRetroAchievements = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableRetroAchievements", defaultValue: true) + emulationOnlyDisableInputRecording = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableInputRecording", defaultValue: true) + emulationOnlyDisableOSD = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableOSD", defaultValue: true) + emulationOnlyDisableFramePacing = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableFramePacing", defaultValue: true) + emulationOnlyDisableVirtualControls = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableVirtualControls", defaultValue: true) + emulationOnlyDisableQuickMenu = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyDisableQuickMenu", defaultValue: true) + emulationOnlyClearNetworkCache = ARMSX2Bridge.getINIBool("ARMSX2iOS/UI", key: "EmulationOnlyClearNetworkCache", defaultValue: true) + emulationOnlyModeDelaySeconds = Self.clamped( + Int(ARMSX2Bridge.getINIInt( + "ARMSX2iOS/UI", key: "EmulationOnlyModeDelaySeconds", + defaultValue: Int32(Self.defaultEmulationOnlyModeDelaySeconds))), + to: Self.emulationOnlyModeDelayRange) eeFpuRoundMode = Self.clampedRoundMode(Int(ARMSX2Bridge.getINIInt("EmuCore/CPU", key: "FPU.Roundmode", defaultValue: 3))) vu0RoundMode = Self.clampedRoundMode(Int(ARMSX2Bridge.getINIInt("EmuCore/CPU", key: "VU0.Roundmode", defaultValue: 3))) vu1RoundMode = Self.clampedRoundMode(Int(ARMSX2Bridge.getINIInt("EmuCore/CPU", key: "VU1.Roundmode", defaultValue: 3))) @@ -2392,6 +2551,17 @@ final class SettingsStore { vu1Recompiler = true fastBoot = false fastmem = true + emulationOnlyModeEnabled = false + emulationOnlyDisablePatches = true + emulationOnlyDisablePINE = true + emulationOnlyDisableRetroAchievements = true + emulationOnlyDisableInputRecording = true + emulationOnlyDisableOSD = true + emulationOnlyDisableFramePacing = true + emulationOnlyDisableVirtualControls = true + emulationOnlyDisableQuickMenu = true + emulationOnlyClearNetworkCache = true + emulationOnlyModeDelaySeconds = Self.defaultEmulationOnlyModeDelaySeconds eeFpuRoundMode = 3 // Chop (Zero) vu0RoundMode = 3 vu1RoundMode = 3 diff --git a/platforms/ios/app/src/main/swift/Models/VPadSkinLibraryStore.swift b/platforms/ios/app/src/main/swift/Models/VPadSkinLibraryStore.swift index 43769258af..85cb49acfc 100644 --- a/platforms/ios/app/src/main/swift/Models/VPadSkinLibraryStore.swift +++ b/platforms/ios/app/src/main/swift/Models/VPadSkinLibraryStore.swift @@ -353,6 +353,43 @@ final class VPadSkinLibraryStore: @unchecked Sendable { ) } + @discardableResult + func importSkinArchive( + from sourceURL: URL, + layoutPresets: PadLayoutPresetStore + ) throws -> VPadSkinImportResult { + let accessGranted = sourceURL.startAccessingSecurityScopedResource() + defer { + if accessGranted { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let stagingDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ARMSX2SkinImport-\(UUID().uuidString)", isDirectory: true) + let archiveDirectory = stagingDirectory.appendingPathComponent("Package", isDirectory: true) + try FileManager.default.createDirectory(at: stagingDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: stagingDirectory) + } + + let isV2Package = SkinManifestImporter.shouldTreatAsV2( + manifestData: ARMSX2Bridge.peekSkinManifestData(at: sourceURL) + ) + let extracted = isV2Package + ? ARMSX2Bridge.extractSkinPackageArchive(at: sourceURL, to: archiveDirectory) + : ARMSX2Bridge.extractControllerSkinArchive(at: sourceURL, to: archiveDirectory) + guard !extracted.isEmpty else { + throw VPadSkinLibraryStoreError.noUsableSkinImages + } + + return try importSkin( + from: archiveDirectory, + originalImportName: sourceURL.lastPathComponent, + layoutPresets: layoutPresets + ) + } + private func importV2ManifestSkin(from sourceURL: URL, originalImportName: String?) -> SkinManifestImporter.V2ImportDecision { switch SkinManifestImporter.detectPackage(sourceURL: sourceURL) { case .legacy: diff --git a/platforms/ios/app/src/main/swift/Views/BIOSListView.swift b/platforms/ios/app/src/main/swift/Views/BIOSListView.swift index a9c23a0fa8..9b59dd6da7 100644 --- a/platforms/ios/app/src/main/swift/Views/BIOSListView.swift +++ b/platforms/ios/app/src/main/swift/Views/BIOSListView.swift @@ -16,16 +16,21 @@ struct BIOSListView: View { @State private var existingBIOSImportFileNames: [String] = [] @Environment(\.menuTabIsActive) private var menuTabIsActive + private var backgroundConfigured: Bool { + settings.hasCustomBackground && settings.backgroundEnabledInBIOS + } + private var backgroundActive: Bool { - settings.hasCustomBackground && settings.backgroundEnabledInBIOS && menuTabIsActive + backgroundConfigured && menuTabIsActive } var body: some View { NavigationStack { ZStack { - if backgroundActive { - MenuBackgroundLayer() + if backgroundConfigured { + MenuBackgroundLayer(isActive: menuTabIsActive) } + Group { if bioses.isEmpty { emptyState @@ -108,6 +113,9 @@ struct BIOSListView: View { } } .onAppear { loadBIOSes() } + .onReceive(NotificationCenter.default.publisher(for: InitialContentBootstrap.didChangeNotification)) { _ in + loadBIOSes() + } } private func presentMenuPanel(_ name: String, _ action: @escaping () -> Void) { diff --git a/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicBackgrounds.swift b/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicBackgrounds.swift index 2270597977..df6f34bff3 100644 --- a/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicBackgrounds.swift +++ b/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicBackgrounds.swift @@ -1318,7 +1318,7 @@ enum PlayStation2MenuGeometry { struct PlayStation2MenuBackground: View { let theme: DynamicBackgroundTheme - @State private var cameraStartDate = Date() + @Environment(\.menuBackgroundSessionStart) private var cameraStartDate var body: some View { let framesPerSecond = max( @@ -3197,8 +3197,26 @@ private struct XMBParticleUniforms { var waveFollowing: SIMD4 } +private struct XMBSessionRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed == 0 ? 0x9e37_79b9_7f4a_7c15 : seed + } + + mutating func next() -> UInt64 { + state ^= state >> 12 + state ^= state << 25 + state ^= state >> 27 + return state &* 0x2545_f491_4f6c_dd1d + } +} + private final class PlayStation3XMBMartMetalRenderer: NSObject, MTKViewDelegate { private let renderMode: PlayStation3XMBMartRenderMode + private let sessionStartTime: TimeInterval + private let particleTimeOffset: TimeInterval + private let particleSeed: UInt64 private let commandQueue: MTLCommandQueue private let backgroundPipeline: MTLRenderPipelineState private let wavePipeline: MTLRenderPipelineState @@ -3227,11 +3245,14 @@ private final class PlayStation3XMBMartMetalRenderer: NSObject, MTKViewDelegate * PlayStation3XMBMartSplinePipeline.textureHeight ) private var splineTime: TimeInterval = 0 - private var particleTime = Double.random(in: 0..<1000) - private var previousFrameTime: CFTimeInterval? + private var particleTime: TimeInterval = 0 @MainActor - init?(view: MTKView, renderMode: PlayStation3XMBMartRenderMode) { + init?( + view: MTKView, + renderMode: PlayStation3XMBMartRenderMode, + sessionStartTime: TimeInterval + ) { guard let device = view.device, let commandQueue = device.makeCommandQueue(), let library = PlayStation3XMBByMartShaderLibrary.makeLibrary(device: device), @@ -3313,6 +3334,16 @@ private final class PlayStation3XMBMartMetalRenderer: NSObject, MTKViewDelegate } self.renderMode = renderMode + self.sessionStartTime = sessionStartTime + self.particleTimeOffset = + sessionStartTime.truncatingRemainder(dividingBy: 1000) + let renderModeSalt: UInt64 = + switch renderMode { + case .fullBackground: 0x1465_0fb0_739d_0383 + case .foregroundOnly: 0x9e37_79b9_7f4a_7c15 + case .particlesOnly: 0xd1b5_4a32_d192_ed03 + } + self.particleSeed = sessionStartTime.bitPattern ^ renderModeSalt self.commandQueue = commandQueue self.backgroundPipeline = backgroundPipeline self.wavePipeline = wavePipeline @@ -3353,13 +3384,9 @@ private final class PlayStation3XMBMartMetalRenderer: NSObject, MTKViewDelegate func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} func draw(in view: MTKView) { - let frameTime = CACurrentMediaTime() - if let previousFrameTime { - let delta = max(0, frameTime - previousFrameTime) - splineTime += delta - particleTime += delta - } - previousFrameTime = frameTime + let elapsed = max(0, CFAbsoluteTimeGetCurrent() - sessionStartTime) + splineTime = elapsed + particleTime = particleTimeOffset + elapsed if let theme { let paletteTime = CFAbsoluteTimeGetCurrent() @@ -3587,7 +3614,9 @@ private final class PlayStation3XMBMartMetalRenderer: NSObject, MTKViewDelegate } private func rebuildParticles(device: MTLDevice, count: Int) { - var generator = SystemRandomNumberGenerator() + var generator = XMBSessionRandomNumberGenerator( + seed: particleSeed ^ UInt64(count) + ) let seeds = (0..( Float.random(in: 0..<1, using: &generator), @@ -3714,11 +3743,12 @@ struct PlayStation3XMBMartParticleControls { struct PlayStation3XMBMartMetalSurface: UIViewRepresentable { let settings: PlayStation3XMBSettings let theme: DynamicBackgroundTheme + let sessionStartTime: TimeInterval var renderMode: PlayStation3XMBMartRenderMode = .fullBackground var particleControls = PlayStation3XMBMartParticleControls() func makeCoordinator() -> Coordinator { - Coordinator(renderMode: renderMode) + Coordinator(renderMode: renderMode, sessionStartTime: sessionStartTime) } func makeUIView(context: Context) -> MTKView { @@ -3768,16 +3798,25 @@ struct PlayStation3XMBMartMetalSurface: UIViewRepresentable { final class Coordinator { fileprivate var renderer: PlayStation3XMBMartMetalRenderer? private let renderMode: PlayStation3XMBMartRenderMode + private let sessionStartTime: TimeInterval private var retainsShaderLibrary = false - init(renderMode: PlayStation3XMBMartRenderMode) { + init( + renderMode: PlayStation3XMBMartRenderMode, + sessionStartTime: TimeInterval + ) { self.renderMode = renderMode + self.sessionStartTime = sessionStartTime } @MainActor func attach(to view: MTKView) { guard renderer == nil else { return } - guard let renderer = PlayStation3XMBMartMetalRenderer(view: view, renderMode: renderMode) else { + guard let renderer = PlayStation3XMBMartMetalRenderer( + view: view, + renderMode: renderMode, + sessionStartTime: sessionStartTime + ) else { PlayStation3XMBByMartShaderLibrary.releaseIfUnused() return } @@ -3805,6 +3844,7 @@ struct PlayStation3XMBMartMetalSurface: UIViewRepresentable { // Native SwiftUI adaptation of Mart's MIT-licensed PlayStation 3 XMB recreation. struct PlayStation3XMBByMartBackground: View { let theme: DynamicBackgroundTheme + @Environment(\.menuBackgroundSessionStart) private var menuBackgroundSessionStart @ViewBuilder var body: some View { @@ -3815,6 +3855,7 @@ struct PlayStation3XMBByMartBackground: View { PlayStation3XMBMartMetalSurface( settings: settings, theme: theme, + sessionStartTime: menuBackgroundSessionStart.timeIntervalSinceReferenceDate, renderMode: .foregroundOnly ) } @@ -3823,7 +3864,8 @@ struct PlayStation3XMBByMartBackground: View { } else { PlayStation3XMBMartMetalSurface( settings: settings, - theme: theme + theme: theme, + sessionStartTime: menuBackgroundSessionStart.timeIntervalSinceReferenceDate ) .ignoresSafeArea() .accessibilityHidden(true) diff --git a/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicEffects.swift b/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicEffects.swift index 6a9bd5a794..42026e6624 100644 --- a/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicEffects.swift +++ b/platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicEffects.swift @@ -381,6 +381,7 @@ enum DynamicBackgroundGeometry { struct DynamicParticleOverlay: View { let theme: DynamicBackgroundTheme + @Environment(\.menuBackgroundSessionStart) private var menuBackgroundSessionStart @ViewBuilder var body: some View { @@ -388,6 +389,7 @@ struct DynamicParticleOverlay: View { PlayStation3XMBMartMetalSurface( settings: settings.playStation3XMB, theme: theme, + sessionStartTime: menuBackgroundSessionStart.timeIntervalSinceReferenceDate, renderMode: .particlesOnly, particleControls: martParticleControls ) diff --git a/platforms/ios/app/src/main/swift/Views/Background/MenuBackgroundSupport.swift b/platforms/ios/app/src/main/swift/Views/Background/MenuBackgroundSupport.swift index 0e61d09fae..3ce0489cbf 100644 --- a/platforms/ios/app/src/main/swift/Views/Background/MenuBackgroundSupport.swift +++ b/platforms/ios/app/src/main/swift/Views/Background/MenuBackgroundSupport.swift @@ -2,15 +2,182 @@ // SPDX-License-Identifier: GPL-3.0+ import SwiftUI +import UIKit + +private struct MenuBackgroundHostEnvironmentKey: EnvironmentKey { + static let defaultValue: PersistentMenuBackgroundHost? = nil +} + +extension EnvironmentValues { + var menuBackgroundHost: PersistentMenuBackgroundHost? { + get { self[MenuBackgroundHostEnvironmentKey.self] } + set { self[MenuBackgroundHostEnvironmentKey.self] = newValue } + } +} + +/// Owns exactly one live menu background renderer and moves its UIKit surface +/// between the selected tab's background attachment point. Reparenting preserves +/// video/display-link/Metal state instead of rebuilding those resources whenever +/// the user selects another tab. +@MainActor +final class PersistentMenuBackgroundHost: ObservableObject { + let sessionStart = Date() + + private var hostingController: UIHostingController? + private weak var activeAttachment: UIView? + private var hasLoadedRenderer = false + private var selectedTabAllowsBackground = true + private var exclusivePreviewDepth = 0 + + func attach(to attachment: UIView) { + activeAttachment = attachment + guard selectedTabAllowsBackground, exclusivePreviewDepth == 0 else { + return + } + + let controller = makeHostingControllerIfNeeded() + loadRendererIfNeeded(in: controller) + + controller.loadViewIfNeeded() + guard let hostedView = controller.view else { return } + if hostedView.superview !== attachment { + hostedView.removeFromSuperview() + hostedView.frame = attachment.bounds + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + attachment.insertSubview(hostedView, at: 0) + } + hostedView.isHidden = false + } + + func setSelectedTabAllowsBackground(_ isAllowed: Bool) { + selectedTabAllowsBackground = isAllowed + if !isAllowed { + unloadRenderer() + } + } + + /// The Appearance screen uses the same expensive renderer in its preview. + /// Temporarily unload the full-screen copy so only one background renderer + /// can exist while that preview is visible. + func beginExclusivePreview() { + exclusivePreviewDepth += 1 + if exclusivePreviewDepth == 1 { + unloadRenderer() + } + } + + func endExclusivePreview() { + exclusivePreviewDepth = max(0, exclusivePreviewDepth - 1) + guard exclusivePreviewDepth == 0, + selectedTabAllowsBackground, + let activeAttachment else { + return + } + attach(to: activeAttachment) + } + + /// Stops the renderer only when the selected destination is configured not + /// to show a background. Normal tab changes between enabled destinations do + /// not call this method. + func suspend() { + activeAttachment = nil + unloadRenderer() + } + + private func unloadRenderer() { + hostingController?.view.removeFromSuperview() + guard let hostingController, hasLoadedRenderer else { return } + hostingController.rootView = AnyView(Color.clear) + hasLoadedRenderer = false + } + + /// Destroys the hosted SwiftUI tree when the complete menu hierarchy leaves + /// the root (for example, when gameplay begins). + func release() { + suspend() + hostingController = nil + } + + private func makeHostingControllerIfNeeded() -> UIHostingController { + if let hostingController { + return hostingController + } + + let controller = UIHostingController(rootView: AnyView(Color.clear)) + controller.view.backgroundColor = .clear + controller.view.isOpaque = false + controller.view.isUserInteractionEnabled = false + hostingController = controller + return controller + } + + private func loadRendererIfNeeded(in controller: UIHostingController) { + guard !hasLoadedRenderer else { return } + controller.rootView = AnyView( + GeometryReader { geometry in + BackgroundContainerView(size: geometry.size) + } + .environment(\.menuBackgroundSessionStart, sessionStart) + .ignoresSafeArea() + .accessibilityHidden(true) + .allowsHitTesting(false) + ) + hasLoadedRenderer = true + } +} + +private final class MenuBackgroundAttachmentView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + isUserInteractionEnabled = false + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private struct PersistentMenuBackgroundAttachment: UIViewRepresentable { + let host: PersistentMenuBackgroundHost + let isActive: Bool + + func makeUIView(context: Context) -> MenuBackgroundAttachmentView { + MenuBackgroundAttachmentView() + } + + func updateUIView(_ uiView: MenuBackgroundAttachmentView, context: Context) { + if isActive { + host.attach(to: uiView) + } + } +} struct MenuBackgroundLayer: View { + var isActive = true + @Environment(\.menuBackgroundHost) private var persistentHost + + @ViewBuilder var body: some View { - GeometryReader { geometry in - BackgroundContainerView(size: geometry.size) + if let persistentHost { + PersistentMenuBackgroundAttachment( + host: persistentHost, + isActive: isActive + ) + .ignoresSafeArea() + .accessibilityHidden(true) + .allowsHitTesting(false) + } else if isActive { + GeometryReader { geometry in + BackgroundContainerView(size: geometry.size) + } + .ignoresSafeArea() + .accessibilityHidden(true) + .allowsHitTesting(false) } - .ignoresSafeArea() - .accessibilityHidden(true) - .allowsHitTesting(false) } } diff --git a/platforms/ios/app/src/main/swift/Views/BootSplashView.swift b/platforms/ios/app/src/main/swift/Views/BootSplashView.swift index 4991612f18..1d1111d070 100644 --- a/platforms/ios/app/src/main/swift/Views/BootSplashView.swift +++ b/platforms/ios/app/src/main/swift/Views/BootSplashView.swift @@ -7,24 +7,42 @@ import UIKit struct BootSplashView: View { private static let hardTimeout: UInt64 = 6_000_000_000 + private static let idleVMPrewarmResolved = Notification.Name( + "ARMSX2iOSIdleVMPrewarmResolved" + ) let onFinished: () -> Void @State private var finished = false + @State private var playbackReady = ARMSX2Bridge.isIdleVMPrewarmResolved() var body: some View { ZStack { Color.black .ignoresSafeArea() - BootSplashPlayerView(onFinished: finish) + BootSplashPlayerView(shouldPlay: playbackReady, onFinished: finish) .ignoresSafeArea() } .contentShape(Rectangle()) .onTapGesture { finish() } - .task { + .onAppear { + if ARMSX2Bridge.isIdleVMPrewarmResolved() { + playbackReady = true + } + } + .onReceive(NotificationCenter.default.publisher(for: Self.idleVMPrewarmResolved)) { _ in + playbackReady = true + } + .task(id: playbackReady) { + guard playbackReady else { + return + } try? await Task.sleep(nanoseconds: Self.hardTimeout) + guard !Task.isCancelled else { + return + } finish() } } @@ -41,6 +59,7 @@ struct BootSplashView: View { } private struct BootSplashPlayerView: UIViewRepresentable { + let shouldPlay: Bool let onFinished: () -> Void func makeCoordinator() -> Coordinator { @@ -62,15 +81,19 @@ private struct BootSplashPlayerView: UIViewRepresentable { let item = AVPlayerItem(url: url) let player = AVPlayer(playerItem: item) player.actionAtItemEnd = .pause - context.coordinator.player = player - context.coordinator.observe(item: item) + context.coordinator.configure(player: player, item: item) view.playerLayer.player = player - player.play() + if shouldPlay { + context.coordinator.startIfNeeded() + } return view } func updateUIView(_ uiView: BootSplashPlayerUIView, context: Context) { + if shouldPlay { + context.coordinator.startIfNeeded() + } } static func dismantleUIView(_ uiView: BootSplashPlayerUIView, coordinator: Coordinator) { @@ -84,6 +107,7 @@ private struct BootSplashPlayerView: UIViewRepresentable { private let onFinished: () -> Void private var endToken: NSObjectProtocol? private var errorToken: NSObjectProtocol? + private var hasStarted = false init(onFinished: @escaping () -> Void) { self.onFinished = onFinished @@ -93,8 +117,9 @@ private struct BootSplashPlayerView: UIViewRepresentable { stopObserving() } - func observe(item: AVPlayerItem) { + func configure(player: AVPlayer, item: AVPlayerItem) { stopObserving() + self.player = player endToken = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, @@ -113,6 +138,16 @@ private struct BootSplashPlayerView: UIViewRepresentable { } } + func startIfNeeded() { + guard !hasStarted, let player else { + return + } + + hasStarted = true + player.seek(to: .zero) + player.play() + } + func stopObserving() { if let endToken { NotificationCenter.default.removeObserver(endToken) @@ -122,7 +157,9 @@ private struct BootSplashPlayerView: UIViewRepresentable { NotificationCenter.default.removeObserver(errorToken) self.errorToken = nil } + player?.pause() player = nil + hasStarted = false } func finish() { diff --git a/platforms/ios/app/src/main/swift/Views/CheatsPatchesManagerView.swift b/platforms/ios/app/src/main/swift/Views/CheatsPatchesManagerView.swift index 81ea878c08..0d338aa826 100644 --- a/platforms/ios/app/src/main/swift/Views/CheatsPatchesManagerView.swift +++ b/platforms/ios/app/src/main/swift/Views/CheatsPatchesManagerView.swift @@ -23,6 +23,7 @@ struct CheatsPatchesManagerView: View { @State private var pendingRemoval: InstalledFileRemoval? @State private var pendingEntryRemoval: PatchEntry? @State private var showAdvanced = false + @State private var downloadTask: Task? @Environment(\.dismiss) private var dismiss init( @@ -73,6 +74,10 @@ struct CheatsPatchesManagerView: View { patchSourcesDraft = store.patchDatabaseURLTemplates cheatSourcesDraft = store.cheatDatabaseURLTemplates } + .onDisappear { + downloadTask?.cancel() + downloadTask = nil + } .sheet(isPresented: $showImportPicker) { ImportDocumentPicker( allowedContentTypes: FileImportHandler.pnachContentTypes, @@ -443,7 +448,7 @@ struct CheatsPatchesManagerView: View { if store.hasConfiguredPatchDatabase { Button { store.dismissMessage() - Task { await store.downloadFromDatabase(forISO: isoName, asCheat: false) } + startDatabaseDownload(asCheat: false) } label: { Label( hasDatabasePatch ? "Reinstall Patches" : "Download Patches", @@ -461,7 +466,7 @@ struct CheatsPatchesManagerView: View { if store.hasConfiguredCheatDatabase { Button { store.dismissMessage() - Task { await store.downloadFromDatabase(forISO: isoName, asCheat: true) } + startDatabaseDownload(asCheat: true) } label: { Label( hasDatabaseCheat ? "Reinstall Cheats" : "Download Cheats", @@ -497,6 +502,13 @@ struct CheatsPatchesManagerView: View { store.installed.contains { $0.source == .database && $0.isCheat } } + private func startDatabaseDownload(asCheat: Bool) { + downloadTask?.cancel() + downloadTask = Task { + await store.downloadFromDatabase(forISO: isoName, asCheat: asCheat) + } + } + // MARK: - Import private var importSection: some View { diff --git a/platforms/ios/app/src/main/swift/Views/Controller/DynamicThumbstickControls.swift b/platforms/ios/app/src/main/swift/Views/Controller/DynamicThumbstickControls.swift new file mode 100644 index 0000000000..da24d43315 --- /dev/null +++ b/platforms/ios/app/src/main/swift/Views/Controller/DynamicThumbstickControls.swift @@ -0,0 +1,1650 @@ +// DynamicThumbstickControls.swift — Floating sticks, swipe camera, gyro, and touch actions +// SPDX-License-Identifier: GPL-3.0+ + +import CoreMotion +import QuartzCore +import SwiftUI +import UIKit + +struct DynamicThumbstickVector: Equatable, Sendable { + var x: CGFloat + var y: CGFloat + + static let zero = DynamicThumbstickVector(x: 0, y: 0) + + var magnitude: CGFloat { + hypot(x, y) + } + + func limited(to maximumMagnitude: CGFloat) -> DynamicThumbstickVector { + let magnitude = magnitude + guard magnitude > maximumMagnitude, magnitude > 0 else { return self } + let scale = maximumMagnitude / magnitude + return DynamicThumbstickVector(x: x * scale, y: y * scale) + } +} + +enum DynamicCrosshairMotionSource: Hashable { + case swipe + case thumbstick + case gyroscope +} + +struct DynamicThumbstickSample { + let input: DynamicThumbstickVector + let rawDistance: CGFloat +} + +enum DynamicThumbstickMath { + static func sample(translation: CGSize, maximumRadius: CGFloat, deadZone: CGFloat) -> DynamicThumbstickSample { + guard maximumRadius > 0 else { + return DynamicThumbstickSample(input: .zero, rawDistance: 0) + } + + let rawDistance = hypot(translation.width, translation.height) + guard rawDistance > 0 else { + return DynamicThumbstickSample(input: .zero, rawDistance: 0) + } + + let safeDeadZone = min(max(deadZone, 0), 0.95) + let cappedDistance = min(rawDistance, maximumRadius) + let normalizedDistance = cappedDistance / maximumRadius + // Begin at a true 0% deadzone so even a tiny drag produces input. The + // configured deadzone grows with travel and reaches its selected value + // at the outer radius, while the remap still preserves full-scale output. + let adaptiveDeadZone = safeDeadZone * normalizedDistance + let magnitude = (normalizedDistance - adaptiveDeadZone) / (1 - adaptiveDeadZone) + + return DynamicThumbstickSample( + input: DynamicThumbstickVector( + x: translation.width / rawDistance * magnitude, + // ARMSX2's existing virtual sticks use screen-space positive Y. + y: translation.height / rawDistance * magnitude + ), + rawDistance: rawDistance + ) + } + + static func trailDotScale(index: Int, rawDistance: CGFloat, maximumRadius: CGFloat, maximumDots: Int = 7) -> CGFloat { + guard index >= 0, index < maximumDots, rawDistance > 0, maximumRadius > 0 else { return 0 } + let progress = min(rawDistance / maximumRadius, 1) * CGFloat(maximumDots) + return min(max(progress - CGFloat(index), 0), 1) + } + + static func trailDotPositionProgress(index: Int, rawDistance: CGFloat, maximumRadius: CGFloat, maximumDots: Int = 7) -> CGFloat { + let scale = trailDotScale(index: index, rawDistance: rawDistance, maximumRadius: maximumRadius, maximumDots: maximumDots) + guard scale > 0 else { return 0 } + return CGFloat(index + 1) / CGFloat(maximumDots + 1) * scale + } +} + +struct DynamicThumbstickView: View { + let isLeft: Bool + let radius: CGFloat + let deadZone: CGFloat + let hapticsEnabled: Bool + let thumbstickOpacity: Double + let baseOpacity: Double + let trailOpacity: Double + var tapActionsEnabled = false + var maximumTapDuration: TimeInterval = 0.30 + var tapTravelTolerance: CGFloat = 12 + var onVector: (DynamicThumbstickVector) -> Void + var onInteractionBegan: () -> Void = {} + var onInteractionActivity: () -> Void = {} + var onInteractionTap: () -> Void = {} + var onInteractionEnded: () -> Void = {} + + @State private var origin = CGPoint.zero + @State private var dragOffset = CGSize.zero + @State private var dragDistance: CGFloat = 0 + @State private var isActive = false + @State private var gestureStartedAt: Date? + @State private var maximumTravel: CGFloat = 0 + + var body: some View { + GeometryReader { _ in + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + + if isActive { + DynamicThumbstickVisual( + radius: radius, + dragOffset: dragOffset, + dragDistance: dragDistance, + thumbstickOpacity: thumbstickOpacity, + baseOpacity: baseOpacity, + trailOpacity: trailOpacity + ) + .position(origin) + .transition(.opacity) + .allowsHitTesting(false) + } + } + .gesture(dragGesture) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(isLeft ? "Dynamic left thumbstick area" : "Dynamic right thumbstick area") + .accessibilityHint("Press and drag anywhere in this area") + .onDisappear(perform: reset) + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + if !isActive { + origin = value.startLocation + dragOffset = .zero + dragDistance = 0 + maximumTravel = 0 + gestureStartedAt = value.time + if tapActionsEnabled { onInteractionBegan() } + withAnimation(.spring(response: 0.20, dampingFraction: 0.78)) { + isActive = true + } + if hapticsEnabled && SettingsStore.shared.hapticFeedback { + HapticManager.light.impactOccurred(intensity: 0.72) + } + } + + maximumTravel = max(maximumTravel, hypot(value.translation.width, value.translation.height)) + if tapActionsEnabled { onInteractionActivity() } + let sample = DynamicThumbstickMath.sample( + translation: value.translation, + maximumRadius: radius, + deadZone: deadZone + ) + dragOffset = value.translation + dragDistance = sample.rawDistance + onVector(sample.input) + } + .onEnded { value in + let duration = value.time.timeIntervalSince(gestureStartedAt ?? value.time) + let travel = max(maximumTravel, hypot(value.translation.width, value.translation.height)) + if duration <= maximumTapDuration && travel <= tapTravelTolerance { + if tapActionsEnabled { + onInteractionTap() + } else { + pulseStickButton() + } + } + reset() + } + } + + private func pulseStickButton() { + let button: ARMSX2PadButton = isLeft ? .L3 : .R3 + EmulatorBridge.shared.setPadButton(button, pressed: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { + EmulatorBridge.shared.setPadButton(button, pressed: false) + } + } + + private func reset() { + guard isActive || gestureStartedAt != nil else { + onVector(.zero) + return + } + onVector(.zero) + if tapActionsEnabled && gestureStartedAt != nil { onInteractionEnded() } + gestureStartedAt = nil + maximumTravel = 0 + withAnimation(.easeOut(duration: 0.14)) { + isActive = false + } + } +} + +private struct DynamicThumbstickVisual: View { + let radius: CGFloat + let dragOffset: CGSize + let dragDistance: CGFloat + let thumbstickOpacity: Double + let baseOpacity: Double + let trailOpacity: Double + + var body: some View { + ZStack { + ForEach(0..<7, id: \.self) { index in + let scale = DynamicThumbstickMath.trailDotScale(index: index, rawDistance: dragDistance, maximumRadius: radius) + let progress = DynamicThumbstickMath.trailDotPositionProgress(index: index, rawDistance: dragDistance, maximumRadius: radius) + if scale > 0 { + Circle() + .fill(Color(white: 0.82).opacity(trailOpacity * Double(scale))) + .frame(width: 7.5, height: 7.5) + .scaleEffect(scale) + .offset(x: dragOffset.width * progress, y: dragOffset.height * progress) + .animation(.spring(response: 0.18, dampingFraction: 0.72), value: scale) + } + } + + Circle() + .fill(Color(white: 0.72).opacity(baseOpacity)) + .frame(width: radius * 0.42, height: radius * 0.42) + + Circle() + .fill(Color(white: 0.88).opacity(thumbstickOpacity)) + .frame(width: radius * 0.64, height: radius * 0.64) + .offset(dragOffset) + } + .frame(width: radius * 2, height: radius * 2) + } +} + +struct VirtualPadCameraSwipeView: View { + let maximumTapDuration: TimeInterval + let tapTravelTolerance: CGFloat + let onDelta: (CGSize) -> Void + let onBegan: () -> Void + let onActivity: () -> Void + let onTap: () -> Void + let onEnded: () -> Void + + @State private var lastLocation: CGPoint? + @State private var gestureStartedAt: Date? + @State private var maximumTravel: CGFloat = 0 + + var body: some View { + GeometryReader { _ in + Color.clear + .contentShape(Rectangle()) + .gesture(swipeGesture) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Camera swipe area") + .accessibilityHint("Swipe to move the emulated right analog stick") + .onDisappear(perform: endInteractionIfNeeded) + } + + private var swipeGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + if gestureStartedAt == nil { + gestureStartedAt = value.time + maximumTravel = 0 + onBegan() + } + maximumTravel = max(maximumTravel, hypot(value.translation.width, value.translation.height)) + onActivity() + defer { lastLocation = value.location } + guard let lastLocation else { return } + let delta = CGSize( + width: value.location.x - lastLocation.x, + height: value.location.y - lastLocation.y + ) + if abs(delta.width) > 0.01 || abs(delta.height) > 0.01 { + onDelta(delta) + } + } + .onEnded { value in + let duration = value.time.timeIntervalSince(gestureStartedAt ?? value.time) + let travel = max(maximumTravel, hypot(value.translation.width, value.translation.height)) + if duration <= maximumTapDuration && travel <= tapTravelTolerance { onTap() } + onEnded() + reset() + } + } + + private func endInteractionIfNeeded() { + if gestureStartedAt != nil { onEnded() } + reset() + } + + private func reset() { + lastLocation = nil + gestureStartedAt = nil + maximumTravel = 0 + } +} + +@MainActor +final class SwipeCameraInputDriver: NSObject { + private var displayLink: CADisplayLink? + private var pendingDelta = CGSize.zero + private var pendingDeltaIsAiming = false + private var lastTimestamp: CFTimeInterval? + private var outputWasActive = false + var onCameraMotion: ((DynamicThumbstickVector) -> Void)? + + func start() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(update(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 120, preferred: 60) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func add(delta: CGSize, isAiming: Bool) { + pendingDelta.width += delta.width + pendingDelta.height += delta.height + pendingDeltaIsAiming = isAiming + } + + func stop() { + displayLink?.invalidate() + displayLink = nil + pendingDelta = .zero + pendingDeltaIsAiming = false + lastTimestamp = nil + outputWasActive = false + onCameraMotion?(.zero) + EmulatorBridge.shared.setRightStick(x: 0, y: 0) + } + + @objc private func update(_ link: CADisplayLink) { + let previous = lastTimestamp ?? (link.timestamp - 1.0 / 60.0) + let deltaTime = min(max(link.timestamp - previous, 1.0 / 240.0), 1.0 / 20.0) + lastTimestamp = link.timestamp + + let delta = pendingDelta + let isAiming = pendingDeltaIsAiming + pendingDelta = .zero + guard delta != .zero else { + if outputWasActive { + outputWasActive = false + onCameraMotion?(.zero) + EmulatorBridge.shared.setRightStick(x: 0, y: 0) + } + return + } + + let settings = DynamicThumbstickSettings.shared + let sensitivity = settings.effectiveSwipeSensitivity(isAiming: isAiming) + let degreesPerSecondX = Double(delta.width) * sensitivity.horizontal / deltaTime + let degreesPerSecondY = Double(delta.height) * sensitivity.vertical / deltaTime + let referenceDegreesPerSecond = 105.0 + let motion = DynamicThumbstickVector( + x: CGFloat(degreesPerSecondX / referenceDegreesPerSecond), + y: CGFloat(degreesPerSecondY / referenceDegreesPerSecond) + ).limited(to: 1) + outputWasActive = true + onCameraMotion?(motion) + EmulatorBridge.shared.setRightStick( + x: Float(motion.x), + y: Float(motion.y) + ) + } +} + +@MainActor +final class VirtualPadGyroscopeController { + private let motionManager = CMMotionManager() + private var filteredRate = DynamicThumbstickVector.zero + var onCameraMotion: ((DynamicThumbstickVector) -> Void)? + + var isAvailable: Bool { motionManager.isGyroAvailable } + + init() { + motionManager.gyroUpdateInterval = 1.0 / 60.0 + } + + func setEnabled(_ enabled: Bool) { + guard enabled, motionManager.isGyroAvailable else { + stop() + return + } + guard !motionManager.isGyroActive else { return } + + motionManager.startGyroUpdates(to: .main) { [weak self] data, _ in + guard let rotationRate = data?.rotationRate else { return } + Task { @MainActor [weak self] in + self?.consume(x: rotationRate.x, y: rotationRate.y) + } + } + } + + func stop() { + motionManager.stopGyroUpdates() + filteredRate = .zero + onCameraMotion?(.zero) + EmulatorBridge.shared.setRightStickMotion(x: 0, y: 0) + } + + private func consume(x: Double, y: Double) { + let raw = Self.screenRate(x: x, y: y, orientation: interfaceOrientation) + let settings = DynamicThumbstickSettings.shared + let retained = min(max(settings.gyroSmoothing, 0), 0.95) + let blend = CGFloat(1 - retained) + filteredRate = DynamicThumbstickVector( + x: filteredRate.x + (raw.x - filteredRate.x) * blend, + y: filteredRate.y + (raw.y - filteredRate.y) * blend + ) + + let processed = Self.processedRate( + filteredRate, + sensitivity: settings.gyroSensitivity, + acceleration: settings.gyroAcceleration, + deadZone: settings.gyroDeadZone, + maximumRate: settings.gyroMaximumRate, + invertHorizontal: settings.invertGyroHorizontal, + invertVertical: settings.invertGyroVertical + ) + let referenceRadiansPerSecond = CGFloat(105.0 * Double.pi / 180.0) + let motion = DynamicThumbstickVector( + x: processed.x / referenceRadiansPerSecond, + y: processed.y / referenceRadiansPerSecond + ) + onCameraMotion?(motion) + EmulatorBridge.shared.setRightStickMotion( + x: Float(motion.x), + y: Float(motion.y) + ) + } + + private var interfaceOrientation: UIInterfaceOrientation { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first(where: { $0.activationState == .foregroundActive })? + .interfaceOrientation ?? .landscapeRight + } + + private static func screenRate(x: Double, y: Double, orientation: UIInterfaceOrientation) -> DynamicThumbstickVector { + switch orientation { + case .landscapeLeft: return DynamicThumbstickVector(x: x, y: -y) + case .landscapeRight: return DynamicThumbstickVector(x: -x, y: y) + case .portraitUpsideDown: return DynamicThumbstickVector(x: y, y: x) + default: return DynamicThumbstickVector(x: -y, y: -x) + } + } + + private static func processedRate( + _ raw: DynamicThumbstickVector, + sensitivity: Double, + acceleration: Double, + deadZone: Double, + maximumRate: Double, + invertHorizontal: Bool, + invertVertical: Bool + ) -> DynamicThumbstickVector { + let magnitude = raw.magnitude + let deadZone = CGFloat(max(deadZone, 0)) + guard magnitude > deadZone, magnitude > 0 else { return .zero } + + let limit = CGFloat(max(maximumRate, 0.01)) + let adjustedMagnitude = min(magnitude - deadZone, limit) + let speed = min(adjustedMagnitude / limit, 1) + let gain = 1 + CGFloat(max(acceleration, 0)) * speed + let outputMagnitude = adjustedMagnitude * CGFloat(max(sensitivity, 0)) * gain + return DynamicThumbstickVector( + x: raw.x / magnitude * outputMagnitude * (invertHorizontal ? -1 : 1), + y: raw.y / magnitude * outputMagnitude * (invertVertical ? -1 : 1) + ) + } +} + +@MainActor +private final class VirtualPadActionPressCoordinator { + static let shared = VirtualPadActionPressCoordinator() + + private var pressedSources: [VirtualPadActionButton: Set] = [:] + + func set(_ button: VirtualPadActionButton, source: String, pressed: Bool) { + var sources = pressedSources[button, default: []] + let wasPressed = !sources.isEmpty + if pressed { + sources.insert(source) + } else { + sources.remove(source) + } + + if sources.isEmpty { + pressedSources.removeValue(forKey: button) + } else { + pressedSources[button] = sources + } + + let isPressed = !sources.isEmpty + if wasPressed != isPressed { + EmulatorBridge.shared.setPadButton(button.padButton, pressed: isPressed) + } + } +} + +@MainActor +@Observable +final class DynamicCrosshairRuntimeState { + private(set) var isAiming = false + private(set) var isCameraMoving = false + private(set) var isCameraSettling = false + private(set) var isShooting = false + private(set) var isRapidFiring = false + private(set) var cameraMotion = DynamicThumbstickVector.zero + private(set) var cameraAcceleration = DynamicThumbstickVector.zero + private(set) var movementStartedAt = 0.0 + private(set) var movementEndedAt = 0.0 + private(set) var shotStartedAt = 0.0 + private(set) var shotTiltRadians: CGFloat = 0 + private(set) var rapidFireStartedAt = 0.0 + + @ObservationIgnored private var sourceMotion: [DynamicCrosshairMotionSource: DynamicThumbstickVector] = [:] + @ObservationIgnored private var settlingTask: Task? + @ObservationIgnored private var shotTask: Task? + + func setAiming(_ aiming: Bool) { + isAiming = aiming + if !aiming { + clearTransientActivity() + } + } + + func updateCameraMotion(_ motion: DynamicThumbstickVector, source: DynamicCrosshairMotionSource) { + guard isAiming else { return } + + let limitedMotion = motion.limited(to: 1.35) + if limitedMotion.magnitude > 0.003 { + sourceMotion[source] = limitedMotion + } else { + sourceMotion.removeValue(forKey: source) + } + + let combined = sourceMotion.values.reduce(DynamicThumbstickVector.zero) { partial, next in + DynamicThumbstickVector(x: partial.x + next.x, y: partial.y + next.y) + } + let target = combined.limited(to: 1.35) + let now = Date.timeIntervalSinceReferenceDate + + guard target.magnitude > 0.003 else { + beginCameraSettle(at: now) + return + } + + if !isCameraMoving { + movementStartedAt = now + } + if isCameraSettling { + settlingTask?.cancel() + settlingTask = nil + } + + let previous = cameraMotion + let response: CGFloat = isCameraMoving ? 0.52 : 0.72 + let filtered = DynamicThumbstickVector( + x: previous.x + (target.x - previous.x) * response, + y: previous.y + (target.y - previous.y) * response + ) + cameraAcceleration = DynamicThumbstickVector( + x: (filtered.x - previous.x) * 2.2, + y: (filtered.y - previous.y) * 2.2 + ).limited(to: 1) + cameraMotion = filtered + isCameraMoving = true + isCameraSettling = false + } + + func triggerShot() { + guard isAiming else { return } + shotStartedAt = Date.timeIntervalSinceReferenceDate + shotTiltRadians = .random(in: -0.07...0.07) + isShooting = true + shotTask?.cancel() + shotTask = nil + guard !isRapidFiring else { return } + shotTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(160)) + guard !Task.isCancelled else { return } + self?.isShooting = false + } + } + + func setRapidFiring(_ firing: Bool) { + guard isAiming || !firing else { return } + if firing && !isRapidFiring { + rapidFireStartedAt = Date.timeIntervalSinceReferenceDate + } + isRapidFiring = firing + if !firing { + shotTask?.cancel() + shotTask = nil + isShooting = false + } + } + + func reset() { + isAiming = false + clearTransientActivity() + } + + private func clearTransientActivity() { + settlingTask?.cancel() + shotTask?.cancel() + settlingTask = nil + shotTask = nil + sourceMotion.removeAll(keepingCapacity: true) + isCameraMoving = false + isCameraSettling = false + isShooting = false + isRapidFiring = false + cameraMotion = .zero + cameraAcceleration = .zero + shotTiltRadians = 0 + } + + private func beginCameraSettle(at now: TimeInterval) { + guard isCameraMoving else { return } + isCameraMoving = false + isCameraSettling = true + movementEndedAt = now + settlingTask?.cancel() + settlingTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(260)) + guard !Task.isCancelled, let self else { return } + self.isCameraSettling = false + self.cameraMotion = .zero + self.cameraAcceleration = .zero + self.settlingTask = nil + } + } +} + +@MainActor +final class VirtualPadTouchActionController { + private let side: VirtualPadThumbstickSide + let crosshairState = DynamicCrosshairRuntimeState() + private var lastInteractionTapTime: CFTimeInterval? + private var lastFireTapTime: CFTimeInterval? + private var fireTapCount = 0 + private var touchActive = false + private var aimEngaged = false + private var enteredAimThisInteraction = false + private var rapidFireEngaged = false + private var aimReleaseTask: Task? + private var fireReleaseTask: Task? + private var fireLoopTask: Task? + private var tapFireTask: Task? + private var pendingSingleFireTask: Task? + private var activeAimButton: VirtualPadActionButton? + private var activeTapFireButton: VirtualPadActionButton? + private var activeHoldFireButton: VirtualPadActionButton? + + init(side: VirtualPadThumbstickSide) { + self.side = side + } + + var isAiming: Bool { aimEngaged } + + func updateCameraMotion(_ motion: DynamicThumbstickVector, source: DynamicCrosshairMotionSource) { + crosshairState.updateCameraMotion(motion, source: source) + } + + func interactionBegan() { + let settings = DynamicThumbstickSettings.shared + let now = CACurrentMediaTime() + touchActive = true + enteredAimThisInteraction = false + aimReleaseTask?.cancel() + fireReleaseTask?.cancel() + + let isDoubleTap = settings.doubleTapToHoldAim && + !aimEngaged && + lastInteractionTapTime.map { now - $0 <= settings.doubleTapWindow } == true + if !aimEngaged && (settings.holdAimWhileSwipe || isDoubleTap) { + aimEngaged = true + enteredAimThisInteraction = true + cancelPendingSingleFire() + resetFireTapSequence() + setAimPressed(true) + } + + let insideRapidWindow = lastFireTapTime.map { now - $0 <= settings.rapidTapWindow } == true + if !insideRapidWindow { fireTapCount = 0 } + if !enteredAimThisInteraction && + settings.rapidTapFireEnabled && + insideRapidWindow && + fireTapCount >= max(settings.rapidTapActivationCount - 1, 1) { + startRapidFire() + } + } + + func interactionActivity() { + guard touchActive else { return } + if DynamicThumbstickSettings.shared.extendFireWhileDragging && rapidFireEngaged { + fireReleaseTask?.cancel() + } + } + + func interactionTapped() { + let settings = DynamicThumbstickSettings.shared + let now = CACurrentMediaTime() + lastInteractionTapTime = now + + // The touch that first engages aim is a mode-changing gesture, not a + // shot. It also must not seed the rapid-fire recognizer. + if enteredAimThisInteraction { + enteredAimThisInteraction = false + resetFireTapSequence() + return + } + + if let lastFireTapTime, now - lastFireTapTime <= settings.rapidTapWindow { + fireTapCount += 1 + } else { + fireTapCount = 1 + } + self.lastFireTapTime = now + + if rapidFireEngaged { + return + } + guard settings.tapToFire else { return } + if settings.doubleTapToHoldAim && !aimEngaged { + scheduleSingleFire(after: settings.doubleTapWindow) + } else { + pulseFire() + } + } + + func interactionEnded() { + touchActive = false + scheduleAimRelease() + guard rapidFireEngaged else { return } + if DynamicThumbstickSettings.shared.releaseFireWhenTouchEnds { + stopRapidFire() + } else { + scheduleFireRelease() + } + } + + func reset() { + aimReleaseTask?.cancel() + fireReleaseTask?.cancel() + fireLoopTask?.cancel() + tapFireTask?.cancel() + pendingSingleFireTask?.cancel() + aimReleaseTask = nil + fireReleaseTask = nil + fireLoopTask = nil + tapFireTask = nil + pendingSingleFireTask = nil + touchActive = false + aimEngaged = false + enteredAimThisInteraction = false + rapidFireEngaged = false + lastInteractionTapTime = nil + resetFireTapSequence() + setAimPressed(false) + setTapFirePressed(false) + setHoldFirePressed(false) + crosshairState.reset() + } + + private func scheduleAimRelease() { + guard aimEngaged else { return } + aimReleaseTask?.cancel() + let delay = max(DynamicThumbstickSettings.shared.aimReleaseDelay, 0) + aimReleaseTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self, !self.touchActive else { return } + self.aimEngaged = false + self.setAimPressed(false) + } + } + + private func scheduleFireRelease() { + fireReleaseTask?.cancel() + let delay = max(DynamicThumbstickSettings.shared.fireReleaseDelay, 0) + fireReleaseTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self, !self.touchActive else { return } + self.stopRapidFire() + } + } + + private func pulseFire() { + tapFireTask?.cancel() + setTapFirePressed(false) + setTapFirePressed(true) + crosshairState.triggerShot() + HapticManager.dynamicActionShot() + tapFireTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(75)) + guard !Task.isCancelled else { return } + self?.setTapFirePressed(false) + } + } + + private func scheduleSingleFire(after delay: TimeInterval) { + pendingSingleFireTask?.cancel() + pendingSingleFireTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(max(delay, 0))) + guard !Task.isCancelled, let self else { return } + self.pendingSingleFireTask = nil + self.pulseFire() + } + } + + private func cancelPendingSingleFire() { + pendingSingleFireTask?.cancel() + pendingSingleFireTask = nil + tapFireTask?.cancel() + tapFireTask = nil + setTapFirePressed(false) + } + + private func resetFireTapSequence() { + lastFireTapTime = nil + fireTapCount = 0 + } + + private func startRapidFire() { + guard !rapidFireEngaged else { return } + cancelPendingSingleFire() + rapidFireEngaged = true + crosshairState.setRapidFiring(true) + HapticManager.dynamicActionRapidFire() + fireLoopTask?.cancel() + fireLoopTask = Task { @MainActor [weak self] in + while !Task.isCancelled, let self, self.rapidFireEngaged { + let interval = max(DynamicThumbstickSettings.shared.automaticFireInterval, 0.06) + self.setHoldFirePressed(true) + self.crosshairState.triggerShot() + try? await Task.sleep(for: .seconds(min(interval * 0.45, 0.05))) + if Task.isCancelled { break } + self.setHoldFirePressed(false) + try? await Task.sleep(for: .seconds(max(interval * 0.55, 0.02))) + } + self?.setHoldFirePressed(false) + } + } + + private func stopRapidFire() { + rapidFireEngaged = false + crosshairState.setRapidFiring(false) + fireLoopTask?.cancel() + fireLoopTask = nil + setHoldFirePressed(false) + } + + private func setAimPressed(_ pressed: Bool) { + crosshairState.setAiming(pressed) + Self.setActionButton( + pressed: pressed, + activeButton: &activeAimButton, + selectedButton: DynamicThumbstickSettings.shared.aimButton(for: side), + source: "\(sourcePrefix).aim" + ) + if pressed { + HapticManager.dynamicActionAim() + } + } + + private func setTapFirePressed(_ pressed: Bool) { + Self.setActionButton( + pressed: pressed, + activeButton: &activeTapFireButton, + selectedButton: DynamicThumbstickSettings.shared.fireButton(for: side), + source: "\(sourcePrefix).tapFire" + ) + } + + private func setHoldFirePressed(_ pressed: Bool) { + Self.setActionButton( + pressed: pressed, + activeButton: &activeHoldFireButton, + selectedButton: DynamicThumbstickSettings.shared.holdFireButton(for: side), + source: "\(sourcePrefix).holdFire" + ) + } + + private var sourcePrefix: String { + side == .left ? "leftThumbstick" : "rightThumbstick" + } + + private static func setActionButton( + pressed: Bool, + activeButton: inout VirtualPadActionButton?, + selectedButton: VirtualPadActionButton, + source: String + ) { + if pressed { + if let previous = activeButton, previous != selectedButton { + VirtualPadActionPressCoordinator.shared.set(previous, source: source, pressed: false) + } + activeButton = selectedButton + VirtualPadActionPressCoordinator.shared.set(selectedButton, source: source, pressed: true) + } else if let button = activeButton { + VirtualPadActionPressCoordinator.shared.set(button, source: source, pressed: false) + activeButton = nil + } + } +} + +/// Shared ownership boundary for camera-touch actions and their crosshair +/// runtime. GameScreenView keeps this session so the crosshair can render in +/// the Metal viewport while VirtualControllerView continues driving input. +@MainActor +final class VirtualPadTouchActionSession { + let left = VirtualPadTouchActionController(side: .left) + let right = VirtualPadTouchActionController(side: .right) + + func reset() { + left.reset() + right.reset() + } +} + +struct DynamicAimCrosshairOverlay: View { + let settings: DynamicThumbstickSettings + let leftRuntime: DynamicCrosshairRuntimeState + let rightRuntime: DynamicCrosshairRuntimeState + + private var activeRuntime: DynamicCrosshairRuntimeState? { + if rightRuntime.isAiming { return rightRuntime } + if leftRuntime.isAiming { return leftRuntime } + return nil + } + + var body: some View { + ZStack { + if settings.dynamicCrosshairEnabled, let activeRuntime { + DynamicAimCrosshairView( + type: settings.dynamicCrosshairType, + animation: settings.dynamicCrosshairAnimation, + configuredSize: CGFloat(settings.dynamicCrosshairSize), + configuredOpacity: CGFloat(settings.dynamicCrosshairOpacity), + runtime: activeRuntime + ) + .transition(.scale(scale: 0.76).combined(with: .opacity)) + } + } + .animation( + .spring(response: 0.22, dampingFraction: 0.78), + value: settings.dynamicCrosshairEnabled && activeRuntime != nil + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +private struct DynamicAimCrosshairView: View { + let type: DynamicCrosshairType + let animation: DynamicCrosshairAnimation + let configuredSize: CGFloat + let configuredOpacity: CGFloat + let runtime: DynamicCrosshairRuntimeState + + var body: some View { + let isAnimating = runtime.isCameraMoving || + runtime.isCameraSettling || + runtime.isShooting || + runtime.isRapidFiring + TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isAnimating)) { timeline in + Canvas(rendersAsynchronously: true) { context, canvasSize in + let metrics = animationMetrics(at: timeline.date.timeIntervalSinceReferenceDate) + var transformed = context + transformed.translateBy( + x: canvasSize.width / 2 + metrics.offset.width, + y: canvasSize.height / 2 + metrics.offset.height + ) + transformed.rotate(by: .radians(Double(metrics.rotation))) + transformed.scaleBy( + x: metrics.scale * metrics.scaleX, + y: metrics.scale * metrics.scaleY + ) + drawCrosshair( + in: &transformed, + radius: configuredSize / 2, + spread: metrics.spread, + opacity: metrics.opacity * min(max(configuredOpacity, 0), 1), + movementReaction: metrics.movementReaction, + shotReaction: metrics.shotReaction, + rapidFireReaction: metrics.rapidFireReaction, + motion: metrics.motion + ) + } + } + .frame(width: configuredSize * 2.2, height: configuredSize * 2.2) + } + + private func animationMetrics(at time: TimeInterval) -> CrosshairAnimationMetrics { + let moving = runtime.isCameraMoving + let settling = runtime.isCameraSettling + let shooting = runtime.isShooting + let rapid = runtime.isRapidFiring + let movementPhase = max(time - runtime.movementStartedAt, 0) + let settlingProgress = settling + ? min(max((time - runtime.movementEndedAt) / 0.26, 0), 1) + : 0 + let movementDecay: CGFloat = moving ? 1 : (settling ? CGFloat(1 - settlingProgress) : 0) + let motion = DynamicThumbstickVector( + x: runtime.cameraMotion.x * movementDecay, + y: runtime.cameraMotion.y * movementDecay + ) + let acceleration = DynamicThumbstickVector( + x: runtime.cameraAcceleration.x * movementDecay, + y: runtime.cameraAcceleration.y * movementDecay + ) + let speed = min(motion.magnitude, 1.25) + let accelerationStrength = min(acceleration.magnitude, 1) + let normalizedSpeed = min(speed, 1) + let direction = atan2(Double(motion.y), Double(motion.x)) + let shotProgress = shooting ? min(max((time - runtime.shotStartedAt) / 0.16, 0), 1) : 1 + let shotImpulse = shooting ? CGFloat(1 - shotProgress) : 0 + let rapidPhase = max(time - runtime.rapidFireStartedAt, 0) + let liveFrequency = 8 + Double(normalizedSpeed) * 14 + let moveWave = movementDecay * CGFloat(sin(movementPhase * liveFrequency)) + let rapidWave = rapid ? CGFloat(sin(rapidPhase * 34)) : 0 + let perpendicular = DynamicThumbstickVector(x: -motion.y, y: motion.x) + + var result = CrosshairAnimationMetrics() + switch animation { + case .reactive: + result.spread = 1 + speed * 0.20 + moveWave * 0.025 + accelerationStrength * 0.05 + shotImpulse * 0.30 + result.scale = 1 - shotImpulse * 0.10 + (rapid ? rapidWave * 0.045 : 0) + result.offset = CGSize( + width: motion.x * configuredSize * 0.030, + height: motion.y * configuredSize * 0.030 + ) + case .pulse: + result.scale = 1 + moveWave * (0.025 + speed * 0.06) + speed * 0.025 + shotImpulse * 0.22 + + (rapid ? rapidWave * 0.10 : 0) + result.opacity = 1 - abs(moveWave) * speed * 0.08 - (rapid ? abs(rapidWave) * 0.08 : 0) + result.offset = CGSize( + width: motion.x * configuredSize * 0.018, + height: motion.y * configuredSize * 0.018 + ) + case .expand: + result.spread = 1 + speed * 0.34 + abs(moveWave) * speed * 0.09 + shotImpulse * 0.48 + if rapid { result.spread += 0.14 + abs(rapidWave) * 0.16 } + result.scaleX = 1 + abs(motion.x) * 0.09 + result.scaleY = 1 + abs(motion.y) * 0.09 + case .rotate: + result.rotation = CGFloat(movementPhase * (0.45 + Double(speed) * 1.1)) * movementDecay + result.spread = 1 + speed * 0.14 + shotImpulse * 0.28 + result.scale = 1 + shotImpulse * 0.08 + case .recoil: + result.offset.height = motion.y * configuredSize * 0.042 - configuredSize * shotImpulse * 0.16 + result.offset.width = motion.x * configuredSize * 0.042 + + (rapid ? configuredSize * rapidWave * 0.055 : 0) + result.spread = 1 + speed * 0.14 + shotImpulse * 0.20 + case .orbit: + let phase = rapid ? rapidPhase * 7 : movementPhase * (2.5 + Double(speed) * 3) + let activity: CGFloat = rapid ? 1 : speed + result.offset = CGSize( + width: motion.x * configuredSize * 0.035 + + CGFloat(cos(phase + direction)) * configuredSize * 0.055 * activity, + height: motion.y * configuredSize * 0.035 + + CGFloat(sin(phase + direction)) * configuredSize * 0.055 * activity + ) + result.spread = 1 + shotImpulse * 0.32 + case .focus: + result.spread = 0.90 + speed * 0.36 + abs(moveWave) * speed * 0.05 + if shooting { result.spread -= shotImpulse * 0.20 } + if rapid { result.spread += rapidWave * 0.08 } + result.scale = 1 + shotImpulse * 0.08 + result.offset = CGSize( + width: -motion.x * configuredSize * 0.020, + height: -motion.y * configuredSize * 0.020 + ) + case .wave: + result.scale = 1 + moveWave * speed * 0.075 + (rapid ? rapidWave * 0.08 : 0) + result.spread = 1 + speed * 0.10 + shotImpulse * 0.36 + result.opacity = 1 - CGFloat(abs(rapidWave)) * 0.10 + result.offset = CGSize( + width: perpendicular.x * configuredSize * moveWave * 0.035, + height: perpendicular.y * configuredSize * moveWave * 0.035 + ) + case .directional: + result.offset = CGSize( + width: motion.x * configuredSize * 0.085, + height: motion.y * configuredSize * 0.085 + ) + result.scaleX = 1 + abs(motion.x) * 0.14 + result.scaleY = 1 + abs(motion.y) * 0.14 + result.spread = 1 + speed * 0.13 + shotImpulse * 0.30 + case .elastic: + result.offset = CGSize( + width: (-motion.x * 0.075 + acceleration.x * 0.055) * configuredSize, + height: (-motion.y * 0.075 + acceleration.y * 0.055) * configuredSize + ) + result.scaleX = 1 + abs(motion.x) * 0.10 - abs(motion.y) * 0.025 + result.scaleY = 1 + abs(motion.y) * 0.10 - abs(motion.x) * 0.025 + result.spread = 1 + speed * 0.12 + accelerationStrength * 0.12 + shotImpulse * 0.28 + case .parallax: + result.offset = CGSize( + width: -motion.x * configuredSize * 0.095, + height: -motion.y * configuredSize * 0.095 + ) + result.scaleX = 1 + abs(motion.y) * 0.045 + result.scaleY = 1 + abs(motion.x) * 0.045 + result.spread = 1 + speed * 0.18 + shotImpulse * 0.30 + case .velocity: + result.spread = 1 + speed * 0.52 + accelerationStrength * 0.08 + shotImpulse * 0.35 + result.scale = 1 + speed * 0.055 - shotImpulse * 0.08 + result.scaleX = 1 + abs(motion.x) * 0.12 + result.scaleY = 1 + abs(motion.y) * 0.12 + result.offset = CGSize( + width: motion.x * configuredSize * 0.025, + height: motion.y * configuredSize * 0.025 + ) + case .stabilizer: + result.offset = CGSize( + width: -motion.x * configuredSize * 0.042, + height: -motion.y * configuredSize * 0.042 + ) + result.spread = 1 - speed * 0.10 + accelerationStrength * 0.04 + shotImpulse * 0.26 + result.scaleX = 1 + abs(motion.y) * 0.025 + result.scaleY = 1 + abs(motion.x) * 0.025 + case .snap: + result.offset = CGSize( + width: acceleration.x * configuredSize * 0.095, + height: acceleration.y * configuredSize * 0.095 + ) + result.spread = 1 + speed * 0.10 + accelerationStrength * 0.30 + shotImpulse * 0.34 + result.scale = 1 + accelerationStrength * 0.08 + case .drift: + result.offset = CGSize( + width: (motion.x * 0.050 + perpendicular.x * moveWave * 0.030) * configuredSize, + height: (motion.y * 0.050 + perpendicular.y * moveWave * 0.030) * configuredSize + ) + result.spread = 1 + speed * 0.15 + shotImpulse * 0.28 + case .tilt: + result.rotation = runtime.shotTiltRadians * shotImpulse * 1.35 + result.offset = CGSize( + width: motion.x * configuredSize * 0.025, + height: motion.y * configuredSize * 0.025 + ) + result.spread = 1 + speed * 0.16 + shotImpulse * 0.30 + case .bloom: + result.spread = 1 + speed * 0.42 + abs(moveWave) * speed * 0.10 + shotImpulse * 0.45 + result.scale = 1 + speed * 0.07 + moveWave * speed * 0.035 + result.opacity = 1 - speed * 0.12 + abs(moveWave) * speed * 0.04 + result.scaleX = 1 + abs(motion.x) * 0.08 + result.scaleY = 1 + abs(motion.y) * 0.08 + case .directionLock: + result.offset = CGSize( + width: motion.x * configuredSize * 0.045, + height: motion.y * configuredSize * 0.045 + ) + result.scaleY = 1 + speed * 0.18 + result.scaleX = 1 - min(speed * 0.045, 0.045) + result.spread = 1 + speed * 0.20 + shotImpulse * 0.32 + } + + // Every type receives a small live deformation even when its selected + // animation emphasizes only one axis or behavior. + result.scaleX *= 1 + abs(motion.x) * 0.025 + result.scaleY *= 1 + abs(motion.y) * 0.025 + result.offset.width += motion.x * configuredSize * 0.008 + result.offset.height += motion.y * configuredSize * 0.008 + if animation != .tilt { + result.rotation += runtime.shotTiltRadians * shotImpulse + } + result.movementReaction = min(speed + accelerationStrength * 0.22, 1.35) + result.shotReaction = shotImpulse + result.rapidFireReaction = rapid ? 1 + rapidWave * 0.12 : 0 + result.motion = motion + return result + } + + private func drawCrosshair( + in context: inout GraphicsContext, + radius: CGFloat, + spread: CGFloat, + opacity: CGFloat, + movementReaction: CGFloat, + shotReaction: CGFloat, + rapidFireReaction: CGFloat, + motion: DynamicThumbstickVector + ) { + let lineWidth = max(radius / 28, 0.75) + let gap = radius * 0.27 * spread + let armEnd = radius * min(max(spread, 0.72), 1.55) + let fireReaction = max(shotReaction, rapidFireReaction) + let isFiring = fireReaction > 0 + let reactiveColour = isFiring ? Color(red: 1, green: 0.16, blue: 0.10) : .white + let reactiveOpacity = isFiring ? min(opacity, 0.40) : opacity + + switch type { + case .classic: + stroke( + directionalRadialLines( + count: 4, + innerRadius: gap, + outerRadius: armEnd, + motion: motion, + reaction: movementReaction, + radius: radius + ), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .dot: + motionTrailDots( + motion: motion, + radius: radius, + reaction: movementReaction, + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + dot( + at: .zero, + radius: max(radius * 0.075, 1.25), + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .circle: + stroke( + circle(radius: radius * 0.52 * spread), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .circleDot: + stroke( + circle(radius: radius * 0.52 * spread), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + motionTrailDots( + motion: motion, + radius: radius, + reaction: movementReaction, + in: &context, + opacity: reactiveOpacity * 0.75, + foreground: reactiveColour + ) + dot( + at: .zero, + radius: max(radius * 0.06, 1.1), + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .cross: + stroke( + directionalRadialLines( + count: 4, + innerRadius: 0, + outerRadius: armEnd, + motion: motion, + reaction: movementReaction, + radius: radius + ), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .chevron: + var path = Path() + path.move(to: CGPoint(x: -radius * 0.54 * spread, y: radius * 0.28)) + path.addLine(to: CGPoint(x: 0, y: -radius * 0.24 * spread)) + path.addLine(to: CGPoint(x: radius * 0.54 * spread, y: radius * 0.28)) + stroke( + path, + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .brackets: + stroke( + cornerBrackets(radius: radius * spread), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .diamond: + var path = Path() + path.move(to: CGPoint(x: 0, y: -radius * 0.62 * spread)) + path.addLine(to: CGPoint(x: radius * 0.62 * spread, y: 0)) + path.addLine(to: CGPoint(x: 0, y: radius * 0.62 * spread)) + path.addLine(to: CGPoint(x: -radius * 0.62 * spread, y: 0)) + path.closeSubpath() + stroke( + path, + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .shotgun: + stroke( + circle(radius: radius * 0.48 * spread), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + for index in 0..<6 { + let angle = Double(index) * .pi / 3 + dot( + at: CGPoint( + x: CGFloat(cos(angle)) * radius * 0.72 * spread, + y: CGFloat(sin(angle)) * radius * 0.72 * spread + ), + radius: max(radius * 0.045, 1), + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + } + case .sniper: + stroke( + circle(radius: radius * 0.56 * spread), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + stroke( + directionalRadialLines( + count: 4, + innerRadius: radius * 0.68 * spread, + outerRadius: armEnd, + motion: motion, + reaction: movementReaction, + radius: radius + ), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + dot( + at: .zero, + radius: max(radius * 0.05, 1), + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .tactical: + stroke( + directionalRadialLines( + count: 4, + innerRadius: gap * 1.08, + outerRadius: armEnd * 0.88, + motion: motion, + reaction: movementReaction, + radius: radius + ), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + dot( + at: .zero, + radius: max(radius * 0.055, 1.1), + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + var marker = Path() + marker.move(to: CGPoint(x: 0, y: -radius * 0.98 * spread)) + marker.addLine(to: CGPoint(x: -radius * 0.11, y: -radius * 0.80 * spread)) + marker.addLine(to: CGPoint(x: radius * 0.11, y: -radius * 0.80 * spread)) + marker.closeSubpath() + fill( + marker, + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .burst: + stroke( + directionalRadialLines( + count: 8, + innerRadius: gap, + outerRadius: armEnd, + motion: motion, + reaction: movementReaction, + radius: radius + ), + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .fourBoxes: + let boxDistance = radius * ( + 0.52 + + 0.13 * movementReaction + + 0.24 * fireReaction + ) + let boxHalfSide = max(radius * 0.105, 1.25) + for point in [ + CGPoint(x: 0, y: -boxDistance), + CGPoint(x: boxDistance, y: 0), + CGPoint(x: 0, y: boxDistance), + CGPoint(x: -boxDistance, y: 0) + ] { + square( + at: point, + halfSide: boxHalfSide, + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + } + + let restingLineLength = radius * 0.28 + let lineLength = restingLineLength + + radius * 0.26 * movementReaction + + radius * 0.24 * abs(motion.x) + + radius * 0.18 * fireReaction + let lineInner = boxDistance + boxHalfSide + radius * 0.14 + var horizontalLines = Path() + horizontalLines.move(to: CGPoint(x: lineInner, y: 0)) + horizontalLines.addLine(to: CGPoint(x: lineInner + lineLength, y: 0)) + horizontalLines.move(to: CGPoint(x: -lineInner, y: 0)) + horizontalLines.addLine(to: CGPoint(x: -(lineInner + lineLength), y: 0)) + stroke( + horizontalLines, + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .triad: + let outward = 1 + movementReaction * 0.20 + fireReaction * 0.36 + let baseSegmentCentre = radius * 0.58 * outward + let segmentHalfLength = radius * ( + 0.12 + + 0.08 * movementReaction + + 0.06 * fireReaction + ) + var segments = Path() + for angle in [-Double.pi / 6, -5 * Double.pi / 6, Double.pi / 2] { + let direction = CGPoint(x: CGFloat(cos(angle)), y: CGFloat(sin(angle))) + let directionalPush = (direction.x * motion.x + direction.y * motion.y) * radius * 0.13 + let segmentCentre = baseSegmentCentre + directionalPush + segments.move(to: CGPoint( + x: direction.x * (segmentCentre - segmentHalfLength), + y: direction.y * (segmentCentre - segmentHalfLength) + )) + segments.addLine(to: CGPoint( + x: direction.x * (segmentCentre + segmentHalfLength), + y: direction.y * (segmentCentre + segmentHalfLength) + )) + } + stroke( + segments, + in: &context, + lineWidth: lineWidth, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + case .reactiveDot: + let dotScale = 1 + movementReaction * 0.08 + fireReaction * 0.30 + motionTrailDots( + motion: motion, + radius: radius, + reaction: movementReaction, + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + dot( + at: .zero, + radius: max(radius * 0.055, 0.95) * dotScale, + in: &context, + opacity: reactiveOpacity, + foreground: reactiveColour + ) + } + } + + private func directionalRadialLines( + count: Int, + innerRadius: CGFloat, + outerRadius: CGFloat, + motion: DynamicThumbstickVector, + reaction: CGFloat, + radius: CGFloat + ) -> Path { + var path = Path() + for index in 0.. 0.015 else { return } + for index in 1...2 { + let progress = CGFloat(index) / 2 + dot( + at: CGPoint( + x: -motion.x * radius * 0.34 * progress, + y: -motion.y * radius * 0.34 * progress + ), + radius: max(radius * 0.045 * (1 - progress * 0.34), 0.7), + in: &context, + opacity: opacity * (0.34 - progress * 0.12), + foreground: foreground + ) + } + } + + private func radialLines(count: Int, innerRadius: CGFloat, outerRadius: CGFloat) -> Path { + var path = Path() + for index in 0.. Path { + let inner = radius * 0.38 + let outer = radius * 0.72 + var path = Path() + for x: CGFloat in [-1, 1] { + for y: CGFloat in [-1, 1] { + path.move(to: CGPoint(x: x * inner, y: y * outer)) + path.addLine(to: CGPoint(x: x * outer, y: y * outer)) + path.addLine(to: CGPoint(x: x * outer, y: y * inner)) + } + } + return path + } + + private func circle(radius: CGFloat) -> Path { + Path(ellipseIn: CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2)) + } + + private func stroke( + _ path: Path, + in context: inout GraphicsContext, + lineWidth: CGFloat, + opacity: CGFloat, + foreground: Color = .white + ) { + context.stroke(path, with: .color(.black.opacity(0.72 * Double(opacity))), lineWidth: lineWidth + 1.15) + context.stroke(path, with: .color(foreground.opacity(0.96 * Double(opacity))), lineWidth: lineWidth) + } + + private func dot( + at center: CGPoint, + radius: CGFloat, + in context: inout GraphicsContext, + opacity: CGFloat, + foreground: Color = .white + ) { + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + context.fill( + Path(ellipseIn: rect.insetBy(dx: -0.65, dy: -0.65)), + with: .color(.black.opacity(0.72 * Double(opacity))) + ) + context.fill(Path(ellipseIn: rect), with: .color(foreground.opacity(0.96 * Double(opacity)))) + } + + private func square( + at center: CGPoint, + halfSide: CGFloat, + in context: inout GraphicsContext, + opacity: CGFloat, + foreground: Color + ) { + let rect = CGRect( + x: center.x - halfSide, + y: center.y - halfSide, + width: halfSide * 2, + height: halfSide * 2 + ) + context.fill( + Path(rect.insetBy(dx: -0.65, dy: -0.65)), + with: .color(.black.opacity(0.72 * Double(opacity))) + ) + context.fill(Path(rect), with: .color(foreground.opacity(0.96 * Double(opacity)))) + } + + private func fill( + _ path: Path, + in context: inout GraphicsContext, + opacity: CGFloat, + foreground: Color = .white + ) { + context.stroke(path, with: .color(.black.opacity(0.72 * Double(opacity))), lineWidth: 1.15) + context.fill(path, with: .color(foreground.opacity(0.96 * Double(opacity)))) + } +} + +private struct CrosshairAnimationMetrics { + var scale: CGFloat = 1 + var scaleX: CGFloat = 1 + var scaleY: CGFloat = 1 + var spread: CGFloat = 1 + var offset = CGSize.zero + var rotation: CGFloat = 0 + var opacity: CGFloat = 1 + var movementReaction: CGFloat = 0 + var shotReaction: CGFloat = 0 + var rapidFireReaction: CGFloat = 0 + var motion = DynamicThumbstickVector.zero +} diff --git a/platforms/ios/app/src/main/swift/Views/Controller/PressSurface.swift b/platforms/ios/app/src/main/swift/Views/Controller/PressSurface.swift index 9c456f333e..9533fbfa6d 100644 --- a/platforms/ios/app/src/main/swift/Views/Controller/PressSurface.swift +++ b/platforms/ios/app/src/main/swift/Views/Controller/PressSurface.swift @@ -91,6 +91,10 @@ enum ARMSX2VirtualPadMaskImageCache { _ = image(for: button, descriptor: descriptor) } } + + static func releaseForEmulationOnlyMode() { + cachedImages.removeAll(keepingCapacity: false) + } } enum VirtualPadPressSurfacePolicy { diff --git a/platforms/ios/app/src/main/swift/Views/GameListView.swift b/platforms/ios/app/src/main/swift/Views/GameListView.swift index 4c12de1a96..192a0db102 100644 --- a/platforms/ios/app/src/main/swift/Views/GameListView.swift +++ b/platforms/ios/app/src/main/swift/Views/GameListView.swift @@ -169,7 +169,7 @@ struct GameListView: View { @State private var coverWorkTask: Task? @State private var showGameImporter = false @State private var isLoadingGames = false - @State private var showCoverImporter = false + @State private var showCoverImporter = false @State private var showCoverPhotoPicker = false @Environment(\.menuTabIsActive) private var menuTabIsActive @State private var showRestartAlert = false @@ -204,10 +204,6 @@ struct GameListView: View { || settings.backgroundLandscapeAsset != nil } - private var shouldRenderLibraryBackground: Bool { - menuTabIsActive - } - private struct CoverFlowMetrics { let isCompact: Bool let coverWidth: CGFloat @@ -247,8 +243,8 @@ struct GameListView: View { var body: some View { NavigationStack { ZStack { - if hasCustomBackground && shouldRenderLibraryBackground { - MenuBackgroundLayer() + if hasCustomBackground { + MenuBackgroundLayer(isActive: menuTabIsActive) } GeometryReader { geo in @@ -567,6 +563,9 @@ struct GameListView: View { .onReceive(NotificationCenter.default.publisher(for: ExternalGameLibrary.didChangeNotification)) { _ in loadGames(autoDownloadExternalCovers: true) } + .onReceive(NotificationCenter.default.publisher(for: InitialContentBootstrap.didChangeNotification)) { _ in + loadGames(autoDownloadExternalCovers: false) + } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ARMSX2iOSReturnToMenu"))) { _ in restoreCachedGamesIfNeeded() loadGames(autoDownloadExternalCovers: false) @@ -756,11 +755,16 @@ struct GameListView: View { .font(.body) .fontWeight(.medium) .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) if running { Image(systemName: "circle.fill") .font(.system(size: 8)) .foregroundStyle(.green) .accessibilityLabel(settings.localized("Running")) + .fixedSize() } } HStack(spacing: 8) { @@ -775,7 +779,10 @@ struct GameListView: View { } .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) Spacer() Button { toggleFavorite(game) @@ -831,12 +838,15 @@ struct GameListView: View { .foregroundStyle(.primary) .lineLimit(2) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) + .layoutPriority(1) if running { Image(systemName: "circle.fill") .font(.system(size: 7)) .foregroundStyle(.green) .accessibilityLabel(settings.localized("Running")) + .fixedSize() } } // Reserve space for two title lines so cards stay aligned @@ -905,6 +915,8 @@ struct GameListView: View { .font((metrics.isCompact ? Font.subheadline : Font.headline).weight(.semibold)) .multilineTextAlignment(.center) .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) // minHeight keeps cards aligned for 1- vs 2-line titles // while letting Dynamic Type grow beyond it without clipping. .frame(minHeight: metrics.isCompact ? 38 : 46, alignment: .top) diff --git a/platforms/ios/app/src/main/swift/Views/GameScreenView.swift b/platforms/ios/app/src/main/swift/Views/GameScreenView.swift index 48704ddd48..a53a4f4285 100644 --- a/platforms/ios/app/src/main/swift/Views/GameScreenView.swift +++ b/platforms/ios/app/src/main/swift/Views/GameScreenView.swift @@ -8,6 +8,16 @@ import GameController private let runtimeMenuStateChangedNotification = Notification.Name("ARMSX2iOSRuntimeMenuStateChanged") private let retroAchievementsToastNotification = Notification.Name("ARMSX2RetroAchievementsNotification") +private enum EmulationOnlyNativeReleaseFlag { + // Keep these bit positions synchronized with VMManager.h. + static let patches: UInt = 1 << 0 + static let discordPresence: UInt = 1 << 1 + static let pine: UInt = 1 << 2 + static let achievements: UInt = 1 << 3 + static let inputRecording: UInt = 1 << 4 + static let osd: UInt = 1 << 5 +} + private struct RetroAchievementsToast: Equatable { let title: String let message: String @@ -81,13 +91,119 @@ private enum OverlayRoute: Equatable { case pausedPresenting(QuickMenuDestination) } +/// Gameplay presentation used after Emulation-Only Mode finishes startup cleanup. +/// With every release switch enabled, this keeps only the existing Metal surface. +struct EmulationOnlyGameView: View { + @State private var appState = AppState.shared + @State private var dynamicSettings = DynamicThumbstickSettings.shared + @State private var touchActionSession = VirtualPadTouchActionSession() + + @ViewBuilder + var body: some View { + if appState.emulationOnlyPresentation == .minimal { + MetalGameView() + .ignoresSafeArea() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Game display") + .accessibilityAddTraits(.isImage) + .persistentSystemOverlays(.hidden) + .onAppear(perform: preparePresentation) + .onDisappear(perform: releasePresentation) + } else { + retainedGameplayView + .persistentSystemOverlays(.hidden) + .onAppear(perform: preparePresentation) + .onDisappear(perform: releasePresentation) + } + } + + private var retainedGameplayView: some View { + GeometryReader { geometry in + let isLandscape = geometry.size.width > geometry.size.height + + Group { + if appState.emulationOnlyPresentation.showsVirtualControls && !isLandscape { + VStack(spacing: 0) { + let gameHeight = min(geometry.size.width * 3 / 4, geometry.size.height * 0.6) + accessibleMetalSurface + .frame(height: gameHeight) + .clipped() + .overlay { dynamicCrosshairOverlay } + + ZStack { + Color.black + retainedVirtualControls(isLandscape: false) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .ignoresSafeArea(.container, edges: .bottom) + } else { + ZStack { + accessibleMetalSurface + if appState.emulationOnlyPresentation.showsVirtualControls { + retainedVirtualControls(isLandscape: true) + } + dynamicCrosshairOverlay + } + .ignoresSafeArea() + } + } + } + } + + private var accessibleMetalSurface: some View { + MetalGameView() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Game display") + .accessibilityAddTraits(.isImage) + } + + private func retainedVirtualControls(isLandscape: Bool) -> some View { + VirtualControllerView( + isLandscape: isLandscape, + layoutSnapshot: appState.emulationOnlyPresentation.padLayoutSnapshot, + skinDescriptor: appState.emulationOnlyPresentation.padSkinDescriptor, + touchActionSession: touchActionSession + ) + } + + private var dynamicCrosshairOverlay: some View { + DynamicAimCrosshairOverlay( + settings: dynamicSettings, + leftRuntime: touchActionSession.left.crosshairState, + rightRuntime: touchActionSession.right.crosshairState + ) + } + + private func preparePresentation() { + appState.hideStatusBar = true + appState.hideHomeIndicator = true + UIApplication.shared.isIdleTimerDisabled = true + + // GameScreenView's onDisappear can run later in the same SwiftUI update. + // Reassert the retained presentation state after that teardown completes. + DispatchQueue.main.async { + guard appState.isEmulationOnlyMode else { return } + appState.hideStatusBar = true + appState.hideHomeIndicator = true + UIApplication.shared.isIdleTimerDisabled = true + } + } + + private func releasePresentation() { + UIApplication.shared.isIdleTimerDisabled = false + } +} + struct GameScreenView: View { // MARK: - State & Constants @State private var appState = AppState.shared @State private var settings = SettingsStore.shared + @State private var dynamicSettings = DynamicThumbstickSettings.shared @State private var layoutPresets = PadLayoutPresetStore.shared @State private var skinLibrary = VPadSkinLibraryStore.shared + @State private var touchActionSession = VirtualPadTouchActionSession() @State private var userVirtualPadVisible = true @State private var externalControllerConnected = false @State private var fullScreen = false @@ -126,6 +242,7 @@ struct GameScreenView: View { // (pause menu, per-game settings) aren't re-measured on rotation, so we // key them on this to force a fresh layout on a flip. @State private var screenIsLandscape = true + @State private var emulationOnlyTransitionTask: Task? @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -217,10 +334,12 @@ struct GameScreenView: View { VirtualControllerView( isLandscape: true, layoutSnapshot: effectivePadLayoutSnapshot, - skinDescriptor: effectivePadSkinDescriptor + skinDescriptor: effectivePadSkinDescriptor, + touchActionSession: touchActionSession ) .id(padRebuildToken) } + dynamicCrosshairOverlay menuButtonOverlay(isLandscape: true) } .ignoresSafeArea() @@ -238,14 +357,20 @@ struct GameScreenView: View { .accessibilityLabel("Game display") .accessibilityAddTraits(.isImage) .accessibilityHint("VoiceOver image recognition can read on-screen text.") - .overlay { AccessibilityHUDMirror() } + .overlay { + ZStack { + AccessibilityHUDMirror() + dynamicCrosshairOverlay + } + } if effectiveVirtualPadVisible { ZStack { Color.black VirtualControllerView( layoutSnapshot: effectivePadLayoutSnapshot, - skinDescriptor: effectivePadSkinDescriptor + skinDescriptor: effectivePadSkinDescriptor, + touchActionSession: touchActionSession ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -346,6 +471,8 @@ struct GameScreenView: View { Text(settings.localized("Restart the current game? Unsaved progress will be lost.")) } .onAppear { + FrameTimeDynamicResolutionController.shared.resumeAfterEmulationOnlyMode() + GameEventHaptics.shared.prepareForGameplaySession() enterGameplaySystemChromeMode() syncFullscreenStateFromWindow() applyInitialFullscreenPreference() @@ -353,8 +480,10 @@ struct GameScreenView: View { refreshRuntimeMenuState() consumePendingRetroAchievementsToast() startMenuRestorePollingIfNeeded() + enterEmulationOnlyModeIfReady() } .onDisappear { + cancelEmulationOnlyTransition() statusBanner.cancelDismiss() achievementsBanner.cancelDismiss() stopMenuRestorePolling() @@ -400,11 +529,24 @@ struct GameScreenView: View { stopMenuRestorePolling() } } + .onChange(of: settings.emulationOnlyModeEnabled) { _, isEnabled in + if isEnabled { + enterEmulationOnlyModeIfReady() + } else { + cancelEmulationOnlyTransition() + } + } + .onChange(of: appState.emulationOnlyStartupReady) { _, isReady in + if isReady { + enterEmulationOnlyModeIfReady() + } + } .onReceive(NotificationCenter.default.publisher(for: runtimeMenuStateChangedNotification)) { _ in refreshRuntimeMenuState() } .onReceive(NotificationCenter.default.publisher(for: .GCControllerDidConnect)) { _ in refreshExternalControllerConnectionState() + enterEmulationOnlyModeIfReady() } .onReceive(NotificationCenter.default.publisher(for: .GCControllerDidDisconnect)) { _ in refreshExternalControllerConnectionState() @@ -452,6 +594,113 @@ struct GameScreenView: View { .accessibilityHint(settings.localized("Opens the pause menu")) } + @MainActor + private func enterEmulationOnlyModeIfReady() { + guard settings.emulationOnlyModeEnabled, + appState.emulationOnlyStartupReady, + !appState.isEmulationOnlyMode, + ARMSX2Bridge.isVMRunning(), + emulationOnlyTransitionTask == nil + else { + return + } + + let delaySeconds = settings.emulationOnlyModeDelaySeconds + guard delaySeconds > 0 else { + activateEmulationOnlyModeIfReady() + return + } + + emulationOnlyTransitionTask = Task { @MainActor in + do { + try await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) + } catch { + return + } + + guard !Task.isCancelled else { return } + emulationOnlyTransitionTask = nil + activateEmulationOnlyModeIfReady() + } + } + + @MainActor + private func activateEmulationOnlyModeIfReady() { + guard settings.emulationOnlyModeEnabled, + appState.emulationOnlyStartupReady, + !appState.isEmulationOnlyMode, + ARMSX2Bridge.isVMRunning() + else { + return + } + + let hasExternalController = !GCController.controllers().isEmpty + let keepsVirtualControls = + !hasExternalController || + (!settings.emulationOnlyDisableVirtualControls && effectiveVirtualPadVisible) + let keepsQuickMenu = !settings.emulationOnlyDisableQuickMenu + let presentation = EmulationOnlyPresentation( + showsVirtualControls: keepsVirtualControls, + showsQuickMenu: keepsQuickMenu, + padLayoutSnapshot: keepsVirtualControls ? effectivePadLayoutSnapshot : nil, + padSkinDescriptor: keepsVirtualControls ? effectivePadSkinDescriptor : nil + ) + + overlayRoute = .hidden + if keepsQuickMenu { + menuButtonHidden = false + } + statusBanner.cancelDismiss() + achievementsBanner.cancelDismiss() + stopMenuRestorePolling() + + runtimePerGameSettingsEntry = nil + runtimePerGameSettings = nil + runtimePadLayoutIdentity = nil + + if !keepsVirtualControls { + ARMSX2VirtualPadMaskImageCache.releaseForEmulationOnlyMode() + HapticManager.releaseForEmulationOnlyMode() + } + GameEventHaptics.shared.releaseForEmulationOnlyMode() + PatchStore.shared.releasePresentationResources() + if settings.emulationOnlyClearNetworkCache { + URLCache.shared.removeAllCachedResponses() + } + if settings.emulationOnlyDisableFramePacing { + FrameTimeDynamicResolutionController.shared.suspendForEmulationOnlyMode() + } + + ARMSX2Bridge.releaseNonEmulationResources(emulationOnlyNativeReleaseFlags) + ARMSX2Bridge.setVMPaused(false) + appState.enterEmulationOnlyMode(presentation: presentation) + } + + @MainActor + private func cancelEmulationOnlyTransition() { + emulationOnlyTransitionTask?.cancel() + emulationOnlyTransitionTask = nil + } + + private var emulationOnlyNativeReleaseFlags: UInt { + var flags: UInt = 0 + if settings.emulationOnlyDisablePatches { flags |= EmulationOnlyNativeReleaseFlag.patches } + if settings.emulationOnlyDisableDiscordPresence { flags |= EmulationOnlyNativeReleaseFlag.discordPresence } + if settings.emulationOnlyDisablePINE { flags |= EmulationOnlyNativeReleaseFlag.pine } + if settings.emulationOnlyDisableRetroAchievements { flags |= EmulationOnlyNativeReleaseFlag.achievements } + if settings.emulationOnlyDisableInputRecording { flags |= EmulationOnlyNativeReleaseFlag.inputRecording } + if settings.emulationOnlyDisableOSD { flags |= EmulationOnlyNativeReleaseFlag.osd } + return flags + } + + private var dynamicCrosshairOverlay: some View { + DynamicAimCrosshairOverlay( + settings: dynamicSettings, + leftRuntime: touchActionSession.left.crosshairState, + rightRuntime: touchActionSession.right.crosshairState + ) + } + @ViewBuilder private var menuButtonLabel: some View { // Always-rendered SF Symbol mark. A loose PNG was previously loaded here, but @@ -1058,7 +1307,12 @@ struct GameScreenView: View { // MARK: - Virtual Pad private var effectiveVirtualPadVisible: Bool { - userVirtualPadVisible && (!settings.autoHideVirtualPadWhenControllerConnected || !externalControllerConnected) && overlayRoute != .pausedPresenting(.padLayout) + if appState.isEmulationOnlyMode { + return appState.emulationOnlyPresentation.showsVirtualControls + } + return userVirtualPadVisible && + (!settings.autoHideVirtualPadWhenControllerConnected || !externalControllerConnected) && + overlayRoute != .pausedPresenting(.padLayout) } private var effectivePadLayoutSnapshot: PadLayoutSnapshot? { diff --git a/platforms/ios/app/src/main/swift/Views/HelpView.swift b/platforms/ios/app/src/main/swift/Views/HelpView.swift index f7bfc75d19..1197c854f0 100644 --- a/platforms/ios/app/src/main/swift/Views/HelpView.swift +++ b/platforms/ios/app/src/main/swift/Views/HelpView.swift @@ -107,8 +107,12 @@ struct HelpView: View { @State private var selectedTopic: HelpTopic? = .item(section: 0, item: 0) #endif + private var backgroundConfigured: Bool { + settings.hasCustomBackground && settings.backgroundEnabledInHelp + } + private var backgroundActive: Bool { - settings.hasCustomBackground && settings.backgroundEnabledInHelp && menuTabIsActive + backgroundConfigured && menuTabIsActive } var body: some View { @@ -146,9 +150,10 @@ struct HelpView: View { #else NavigationStack { ZStack { - if backgroundActive { - MenuBackgroundLayer() + if backgroundConfigured { + MenuBackgroundLayer(isActive: menuTabIsActive) } + List { ForEach(helpData) { section in Section { diff --git a/platforms/ios/app/src/main/swift/Views/RootView.swift b/platforms/ios/app/src/main/swift/Views/RootView.swift index 5120d4aeec..9dbf36fd9a 100644 --- a/platforms/ios/app/src/main/swift/Views/RootView.swift +++ b/platforms/ios/app/src/main/swift/Views/RootView.swift @@ -8,11 +8,20 @@ private struct MenuTabIsActiveEnvironmentKey: EnvironmentKey { static let defaultValue = true } +private struct MenuBackgroundSessionStartEnvironmentKey: EnvironmentKey { + static let defaultValue = Date() +} + extension EnvironmentValues { var menuTabIsActive: Bool { get { self[MenuTabIsActiveEnvironmentKey.self] } set { self[MenuTabIsActiveEnvironmentKey.self] = newValue } } + + var menuBackgroundSessionStart: Date { + get { self[MenuBackgroundSessionStartEnvironmentKey.self] } + set { self[MenuBackgroundSessionStartEnvironmentKey.self] = newValue } + } } struct RootView: View { @@ -29,7 +38,12 @@ struct RootView: View { .ignoresSafeArea() MenuTabView() case .playing: - GameScreenView() + if appState.isEmulationOnlyMode && + !appState.emulationOnlyPresentation.showsQuickMenu { + EmulationOnlyGameView() + } else { + GameScreenView() + } } if showBootSplash { @@ -61,101 +75,127 @@ struct RootView: View { } struct MenuTabView: View { - @State private var appState = AppState.shared @State private var settings = SettingsStore.shared @State private var selectedTab = 0 + @StateObject private var backgroundHost = PersistentMenuBackgroundHost() private var biosBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInBIOS } private var helpBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInHelp } private var settingsBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInSettings } - var body: some View { -#if targetEnvironment(macCatalyst) - VStack(spacing: 0) { - CatalystMenuTabBar(selectedTab: $selectedTab) - .padding(.top, 8) - .padding(.bottom, 8) + private var selectedTabShowsBackground: Bool { + switch selectedTab { + case 0: + return settings.hasCustomBackground + case 1: + return biosBackgroundActive + case 2: + return helpBackgroundActive + default: + return settingsBackgroundActive + } + } - Group { - switch selectedTab { - case 0: - GameListView() - case 1: - BIOSListView() - case 2: - HelpView() - default: - SettingsRootView() + var body: some View { + Group { +#if targetEnvironment(macCatalyst) + VStack(spacing: 0) { + CatalystMenuTabBar(selectedTab: $selectedTab) + .padding(.top, 8) + .padding(.bottom, 8) + + Group { + switch selectedTab { + case 0: + GameListView() + case 1: + BIOSListView() + case 2: + HelpView() + default: + SettingsRootView() + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .tint(.blue) + .tint(.blue) #else - TabView(selection: $selectedTab) { - // Games tab is NOT wrapped in SafeAreaProtectedMenuTabContent — it - // renders its own edge-to-edge custom wallpaper (BackgroundContainerView) - // inside its NavigationStack ZStack, which must not be clipped by the - // safe-area padding that the other tabs use. - GameListView() - .environment(\.menuTabIsActive, selectedTab == 0) - .tabItem { - Label(settings.localized("Games"), systemImage: "gamecontroller") - } - .tag(0) + TabView(selection: $selectedTab) { + // Games tab is NOT wrapped in SafeAreaProtectedMenuTabContent — it + // renders its own edge-to-edge custom wallpaper (BackgroundContainerView) + // inside its NavigationStack ZStack, which must not be clipped by the + // safe-area padding that the other tabs use. + GameListView() + .environment(\.menuTabIsActive, selectedTab == 0) + .tabItem { + Label(settings.localized("Games"), systemImage: "gamecontroller") + } + .tag(0) - // When a tab's background is active it owns its edge-to-edge MenuBackgroundLayer - // inside its own NavigationStack (matching GameListView), so it must NOT be wrapped - // in SafeAreaProtectedMenuTabContent — the padding would clip the wallpaper. - Group { - if biosBackgroundActive { - BIOSListView() - } else { - SafeAreaProtectedMenuTabContent { BIOSListView() } + // When a tab's background is active it owns its edge-to-edge MenuBackgroundLayer + // inside its own NavigationStack (matching GameListView), so it must NOT be wrapped + // in SafeAreaProtectedMenuTabContent — the padding would clip the wallpaper. + Group { + if biosBackgroundActive { + BIOSListView() + } else { + SafeAreaProtectedMenuTabContent { BIOSListView() } + } } - } .environment(\.menuTabIsActive, selectedTab == 1) .tabItem { Label(settings.localized("BIOS"), systemImage: "cpu") } .tag(1) - Group { - if helpBackgroundActive { - HelpView() - } else { - SafeAreaProtectedMenuTabContent { HelpView() } + Group { + if helpBackgroundActive { + HelpView() + } else { + SafeAreaProtectedMenuTabContent { HelpView() } + } } - } .environment(\.menuTabIsActive, selectedTab == 2) .tabItem { Label(settings.localized("Help"), systemImage: "questionmark.circle") } .tag(2) - Group { - if settingsBackgroundActive { - NavigationStack { - SettingsRootView() - } - } else { - SafeAreaProtectedMenuTabContent { + Group { + if settingsBackgroundActive { NavigationStack { SettingsRootView() } + } else { + SafeAreaProtectedMenuTabContent { + NavigationStack { + SettingsRootView() + } + } } } + .environment(\.menuTabIsActive, selectedTab == 3) + .tabItem { + Label(settings.localized("Settings"), systemImage: "gearshape") + } + .tag(3) } - .environment(\.menuTabIsActive, selectedTab == 3) - .tabItem { - Label(settings.localized("Settings"), systemImage: "gearshape") - } - .tag(3) - } - .tint(.blue) - .modifier(PreventTabBarCollapseModifier()) + .tint(.blue) + .modifier(PreventTabBarCollapseModifier()) #endif + } + .environment(\.menuBackgroundHost, backgroundHost) + .environment(\.menuBackgroundSessionStart, backgroundHost.sessionStart) + .onAppear { + backgroundHost.setSelectedTabAllowsBackground(selectedTabShowsBackground) + } + .onChange(of: selectedTabShowsBackground) { _, showsBackground in + backgroundHost.setSelectedTabAllowsBackground(showsBackground) + } + .onDisappear { + backgroundHost.release() + } } } diff --git a/platforms/ios/app/src/main/swift/Views/Settings/AppearanceSettingsView.swift b/platforms/ios/app/src/main/swift/Views/Settings/AppearanceSettingsView.swift index 70726a1fd5..6a59546861 100644 --- a/platforms/ios/app/src/main/swift/Views/Settings/AppearanceSettingsView.swift +++ b/platforms/ios/app/src/main/swift/Views/Settings/AppearanceSettingsView.swift @@ -18,7 +18,9 @@ struct AppearanceSettingsView: View { @State private var showPrimaryPicker = false @State private var showLandscapePicker = false @State private var isAppearanceVisible = false + @State private var ownsExclusiveBackgroundPreview = false @Environment(\.menuTabIsActive) private var menuTabIsActive + @Environment(\.menuBackgroundHost) private var menuBackgroundHost var body: some View { Form { @@ -131,11 +133,19 @@ struct AppearanceSettingsView: View { .presentationDetents([.large]) } .onAppear { + if !ownsExclusiveBackgroundPreview { + menuBackgroundHost?.beginExclusivePreview() + ownsExclusiveBackgroundPreview = true + } isAppearanceVisible = true dynamicPreferences = settings.dynamicAppearancePreferences } .onDisappear { isAppearanceVisible = false + if ownsExclusiveBackgroundPreview { + menuBackgroundHost?.endExclusivePreview() + ownsExclusiveBackgroundPreview = false + } } } diff --git a/platforms/ios/app/src/main/swift/Views/Settings/EmulatorSettingsView.swift b/platforms/ios/app/src/main/swift/Views/Settings/EmulatorSettingsView.swift index 86fadc3416..47e9465aca 100644 --- a/platforms/ios/app/src/main/swift/Views/Settings/EmulatorSettingsView.swift +++ b/platforms/ios/app/src/main/swift/Views/Settings/EmulatorSettingsView.swift @@ -190,6 +190,55 @@ struct EmulatorSettingsView: View { .foregroundStyle(.secondary) } + Section(settings.localized("Advanced Emulation")) { + Toggle(settings.localized("Emulation-Only Mode"), isOn: $settings.emulationOnlyModeEnabled) + Text(settings.localized("Automatically unloads the selected menus, controls, and optional services for the current emulation session.")) + .font(.caption) + .foregroundStyle(.secondary) + + Group { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(settings.localized("Emulation-Only Mode Timer")) + Spacer() + Text("\(settings.emulationOnlyModeDelaySeconds)s") + .foregroundStyle(.secondary) + .font(.callout.monospacedDigit()) + } + Slider( + value: emulationOnlyModeDelayBinding, + in: Double(SettingsStore.emulationOnlyModeDelayRange.lowerBound)...Double(SettingsStore.emulationOnlyModeDelayRange.upperBound), + step: 1 + ) { + Text(settings.localized("Emulation-Only Mode Timer")) + } minimumValueLabel: { + Text("0s") + } maximumValueLabel: { + Text("15s") + } + } + + Toggle( + settings.localized("Disable Cheats, Widescreen and Dynamic Patches"), + isOn: $settings.emulationOnlyDisablePatches + ) + Toggle(settings.localized("Disable PINE Server"), isOn: $settings.emulationOnlyDisablePINE) + Toggle(settings.localized("Disable RetroAchievements"), isOn: $settings.emulationOnlyDisableRetroAchievements) + Toggle(settings.localized("Disable PCSX2 Input Recording"), isOn: $settings.emulationOnlyDisableInputRecording) + Toggle(settings.localized("Disable OSD and Performance Overlays"), isOn: $settings.emulationOnlyDisableOSD) + Toggle(settings.localized("Disable Frame Pacing"), isOn: $settings.emulationOnlyDisableFramePacing) + Toggle(settings.localized("Disable Virtual Control Layout"), isOn: $settings.emulationOnlyDisableVirtualControls) + Toggle(settings.localized("Disable Quick Menu"), isOn: $settings.emulationOnlyDisableQuickMenu) + Toggle(settings.localized("Clear Network Cache"), isOn: $settings.emulationOnlyClearNetworkCache) + } + .disabled(!settings.emulationOnlyModeEnabled) + + Text(settings.localized("The timer starts after boot patches and replacement-texture startup complete. Discord Presence is always disabled. All visible cleanup switches default ON, preserving the existing maximum-performance behavior. Disable Frame Pacing stops the optional adaptive frame-time monitor; the core limiter and audio/video timing remain active. Turn a switch off to retain that resource. Without an external controller, the current Virtual Control Layout is retained automatically. Turn Disable Quick Menu off to keep the complete Quick Menu available.")) + .font(.caption) + .foregroundStyle(.secondary) + .disabled(!settings.emulationOnlyModeEnabled) + } + Section { Button(settings.localized("Use VU1 Interpreter Preset")) { settings.applyVU1CompatibilityPreset() @@ -300,6 +349,13 @@ struct EmulatorSettingsView: View { String(format: "%.2f FPS", value) } + private var emulationOnlyModeDelayBinding: Binding { + Binding( + get: { Double(settings.emulationOnlyModeDelaySeconds) }, + set: { settings.emulationOnlyModeDelaySeconds = Int($0.rounded()) } + ) + } + /// Compact labeled picker over a fixed ordered option list (round/clamp modes). @ViewBuilder private func modePicker(_ title: String, selection: Binding, labels: [String]) -> some View { diff --git a/platforms/ios/app/src/main/swift/Views/Settings/SettingsPresetsView.swift b/platforms/ios/app/src/main/swift/Views/Settings/SettingsPresetsView.swift new file mode 100644 index 0000000000..fbbb152c37 --- /dev/null +++ b/platforms/ios/app/src/main/swift/Views/Settings/SettingsPresetsView.swift @@ -0,0 +1,424 @@ +// SettingsPresetsView.swift — Cross-category settings presets +// SPDX-License-Identifier: GPL-3.0+ + +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +private extension UTType { + static let armsx2SettingsPreset = UTType( + exportedAs: "com.armsx2.settings-preset", + conformingTo: .plainText + ) +} + +private struct SettingsPresetDocument: FileDocument { + static var readableContentTypes: [UTType] { [.armsx2SettingsPreset, .plainText] } + + var data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +private enum SettingsPresetsSheet: String, Identifiable { + case folderPicker + case presetImporter + + var id: String { rawValue } +} + +private struct SettingsPresetsMessage: Identifiable { + let id = UUID() + let title: String + let text: String +} + +/// Plain system folder picker. The selected URL is forwarded unchanged so its +/// provider-granted access token remains attached. +private struct ARMSX2FolderPicker: UIViewControllerRepresentable { + let onComplete: (Result) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onComplete: onComplete) + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) + picker.allowsMultipleSelection = false + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController( + _ uiViewController: UIDocumentPickerViewController, + context: Context + ) {} + + final class Coordinator: NSObject, UIDocumentPickerDelegate { + private let onComplete: (Result) -> Void + + init(onComplete: @escaping (Result) -> Void) { + self.onComplete = onComplete + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + guard let url = urls.first else { + onComplete(.failure(CocoaError(.fileNoSuchFile))) + return + } + onComplete(.success(url)) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onComplete(.failure(CocoaError(.userCancelled))) + } + } +} + +struct SettingsPresetsView: View { + @State private var settings = SettingsStore.shared + @State private var skinLibrary = VPadSkinLibraryStore.shared + @State private var folderAccess = InitialContentBootstrap.shared + @State private var presentedSheet: SettingsPresetsSheet? + @State private var message: SettingsPresetsMessage? + @State private var exportDocument = SettingsPresetDocument(data: Data()) + @State private var isExportingPreset = false + + var body: some View { + Form { + folderAccessSection + devicePresetsSection + presetFilesSection + } + .navigationTitle(settings.localized("Settings Presets")) + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $presentedSheet) { sheet in + switch sheet { + case .folderPicker: + ARMSX2FolderPicker { result in + handleFolderPickerResult(result) + } + case .presetImporter: + ImportDocumentPicker( + allowedContentTypes: presetImportContentTypes, + allowsMultipleSelection: false, + asCopy: true + ) { result in + handlePresetPickerResult(result) + } + } + } + .fileExporter( + isPresented: $isExportingPreset, + document: exportDocument, + contentType: .armsx2SettingsPreset, + defaultFilename: "ARMSX2 Custom Preset.ini" + ) { result in + switch result { + case .success: + message = SettingsPresetsMessage( + title: "Preset Exported", + text: "The current preset settings were exported as an .ini file." + ) + case .failure(let error): + if !FileImportHandler.isUserCancelledPickerError(error) { + message = SettingsPresetsMessage( + title: "Preset Export Failed", + text: error.localizedDescription + ) + } + } + } + .alert(item: $message) { message in + Alert( + title: Text(settings.localized(message.title)), + message: Text(settings.localized(message.text)), + dismissButton: .default(Text(settings.localized("OK"))) + ) + } + } + + private var folderAccessSection: some View { + Section { + HStack { + Label( + settings.localized("ARMSX2 Folder"), + systemImage: folderAccess.hasSelectedFolder ? "folder.fill.badge.checkmark" : "folder" + ) + Spacer() + Text(folderAccess.selectedFolderName ?? settings.localized("Not Selected")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Button { + presentedSheet = .folderPicker + } label: { + Label( + settings.localized(folderAccess.hasSelectedFolder + ? "Change ARMSX2 Folder" + : "Select ARMSX2 Folder"), + systemImage: "folder.badge.plus" + ) + } + + if folderAccess.hasSelectedFolder { + Button { + scanSelectedFolder() + } label: { + HStack { + Label(settings.localized("Scan Selected Folder"), systemImage: "arrow.clockwise") + Spacer() + if folderAccess.isRunning { + ProgressView() + } + } + } + .disabled(folderAccess.isRunning) + + Button(role: .destructive) { + folderAccess.removeSelectedFolder() + message = SettingsPresetsMessage( + title: "Folder Access Removed", + text: "ARMSX2 will no longer use the saved permission for that folder." + ) + } label: { + Label(settings.localized("Remove Folder Access"), systemImage: "folder.badge.minus") + } + } + } header: { + Text(settings.localized("ARMSX2 Import Folder")) + } footer: { + Text(settings.localized("Selecting the ARMSX2 folder checks its BIOS, GAMES, PRESETS, and SKINS folders once. ZIP skins are imported, newly imported games receive missing covers, and a skin ZIP whose name starts with 1 becomes the default skin and layout. The saved permission is not scanned again on app launch; use Scan Selected Folder when you want to check it again. Existing imported files are not overwritten.")) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var devicePresetsSection: some View { + Section { + ForEach(BuiltInSettingsPreset.allCases) { preset in + Button { + preset.apply(settings: settings, skinLibrary: skinLibrary) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 5) { + Text(settings.localized(preset.rawValue)) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + Text(settings.localized(preset.summary)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + + Spacer() + + if preset.isActive(settings: settings, skinLibrary: skinLibrary) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .fixedSize() + } else { + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .fixedSize() + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } header: { + Text(settings.localized("Device Presets")) + } footer: { + Text(settings.localized(BuiltInSettingsPreset.allCases.map(\.detail).joined(separator: "\n\n") + " Selecting a preset changes only these listed settings.")) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var presetFilesSection: some View { + Section { + Button { + exportDocument = SettingsPresetDocument( + data: SettingsPresetFile.exportData(name: "ARMSX2 Custom Preset", settings: settings) + ) + isExportingPreset = true + } label: { + Label(settings.localized("Export Current Preset"), systemImage: "square.and.arrow.up") + } + + Button { + presentedSheet = .presetImporter + } label: { + Label(settings.localized("Import Preset"), systemImage: "square.and.arrow.down") + } + } header: { + Text(settings.localized("Preset Files")) + } footer: { + Text(settings.localized("Preset files use the .ini extension. Export saves the settings managed by this screen. Import validates and applies only recognized values; unknown keys are ignored.")) + } + } + + private var presetImportContentTypes: [UTType] { + var types: [UTType] = [.armsx2SettingsPreset, .plainText, .text, .data] + if let iniType = UTType(filenameExtension: "ini") { + types.insert(iniType, at: 0) + } + return Array(Set(types)) + } + + private func handleFolderPickerResult(_ result: Result) { + presentedSheet = nil + switch result { + case .success(let url): + Task { @MainActor in + let resultMessage = await folderAccess.selectARMSX2Folder(url) + message = SettingsPresetsMessage( + title: "ARMSX2 Folder", + text: resultMessage + ) + } + case .failure(let error): + if !FileImportHandler.isUserCancelledPickerError(error) { + message = SettingsPresetsMessage( + title: "Folder Selection Failed", + text: error.localizedDescription + ) + } + } + } + + private func scanSelectedFolder() { + Task { @MainActor in + let resultMessage = await folderAccess.scanSelectedFolder() + message = SettingsPresetsMessage( + title: "ARMSX2 Folder", + text: resultMessage + ) + } + } + + private func handlePresetPickerResult(_ result: Result<[URL], Error>) { + presentedSheet = nil + switch result { + case .success(let urls): + guard let url = urls.first else { return } + importPreset(at: url) + case .failure(let error): + if !FileImportHandler.isUserCancelledPickerError(error) { + message = SettingsPresetsMessage( + title: "Preset Import Failed", + text: error.localizedDescription + ) + } + } + } + + private func importPreset(at url: URL) { + guard url.pathExtension.caseInsensitiveCompare("ini") == .orderedSame else { + message = SettingsPresetsMessage( + title: "Preset Import Failed", + text: "Select an ARMSX2 preset with the .ini extension." + ) + return + } + + let stem = url.deletingPathExtension().lastPathComponent + let accessing = url.startAccessingSecurityScopedResource() + defer { + if accessing { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let selectedSkinID = skinLibrary.selectedSkinID + let selectedVirtualPadSkin = settings.virtualPadSkin + let data = try Data(contentsOf: url) + let outcome = try SettingsPresetFile.importData( + data, + fallbackName: stem, + settings: settings, + skinLibrary: skinLibrary + ) + var builtInNames = applyBuiltInPresets(named: stem) + if outcome.name.caseInsensitiveCompare(stem) != .orderedSame { + for name in applyBuiltInPresets(named: outcome.name) + where !builtInNames.contains(name) { + builtInNames.append(name) + } + } + let preservesVirtualPadSkin = builtInNames.contains { name in + BuiltInSettingsPreset(rawValue: name)?.preservesVirtualPadSkin == true + } + if preservesVirtualPadSkin { + skinLibrary.selectSkin(id: selectedSkinID) + settings.virtualPadSkin = selectedVirtualPadSkin + } + let builtInSuffix = builtInNames.isEmpty + ? "" + : " Applied built-in preset: \(builtInNames.joined(separator: ", "))." + message = SettingsPresetsMessage( + title: "Preset Imported", + text: "Applied \(outcome.appliedFieldCount) settings from \(outcome.name).\(builtInSuffix)" + ) + } catch SettingsPresetFileError.noSupportedSettings { + let builtInNames = applyBuiltInPresets(named: stem) + if builtInNames.isEmpty { + message = SettingsPresetsMessage( + title: "Preset Import Failed", + text: SettingsPresetFileError.noSupportedSettings.localizedDescription + ) + } else { + message = SettingsPresetsMessage( + title: "Preset Imported", + text: "Applied built-in preset: \(builtInNames.joined(separator: ", "))." + ) + } + } catch { + message = SettingsPresetsMessage( + title: "Preset Import Failed", + text: error.localizedDescription + ) + } + } + + private func applyBuiltInPresets(named name: String) -> [String] { + let normalizedName = name + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + var applied: [String] = [] + + for preset in BuiltInSettingsPreset.allCases + where preset.rawValue.lowercased() == normalizedName { + preset.apply(settings: settings, skinLibrary: skinLibrary) + applied.append(preset.rawValue) + } + for preset in BuiltInDynamicControlPreset.allCases + where preset.rawValue.lowercased() == normalizedName { + preset.apply() + applied.append(preset.rawValue) + } + return applied + } +} diff --git a/platforms/ios/app/src/main/swift/Views/Settings/SettingsRootView.swift b/platforms/ios/app/src/main/swift/Views/Settings/SettingsRootView.swift index b82d5d667f..f5fbf39a07 100644 --- a/platforms/ios/app/src/main/swift/Views/Settings/SettingsRootView.swift +++ b/platforms/ios/app/src/main/swift/Views/Settings/SettingsRootView.swift @@ -16,6 +16,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case network case memoryCards case storage + case settingsPresets case retroAchievements case overlay case gameController @@ -46,6 +47,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable { return "Memory Cards" case .storage: return "Storage" + case .settingsPresets: + return "Settings Presets" case .retroAchievements: return "RetroAchievements" case .overlay: @@ -83,6 +86,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable { return "memorychip" case .storage: return "internaldrive" + case .settingsPresets: + return "slider.horizontal.3" case .retroAchievements: return "trophy" case .overlay: @@ -112,8 +117,12 @@ struct SettingsRootView: View { @State private var selectedPane: SettingsPane? = .emulator #endif + private var backgroundConfigured: Bool { + settings.hasCustomBackground && settings.backgroundEnabledInSettings + } + private var backgroundActive: Bool { - settings.hasCustomBackground && settings.backgroundEnabledInSettings && menuTabIsActive + backgroundConfigured && menuTabIsActive } var body: some View { @@ -133,9 +142,10 @@ struct SettingsRootView: View { .containerBackground(backgroundActive ? Color.clear : Color(uiColor: .systemGroupedBackground), for: .navigation) #else ZStack { - if backgroundActive { - MenuBackgroundLayer() + if backgroundConfigured { + MenuBackgroundLayer(isActive: menuTabIsActive) } + List { Section(settings.localized("Interface")) { NavigationLink { @@ -210,6 +220,11 @@ struct SettingsRootView: View { } Section(settings.localized("Features")) { + NavigationLink { + SettingsPresetsView() + } label: { + Label(settings.localized("Settings Presets"), systemImage: "slider.horizontal.3") + } NavigationLink { RetroAchievementsSettingsView() } label: { @@ -376,6 +391,8 @@ struct SettingsRootView: View { MemoryCardSettingsView() case .storage: StorageSettingsView() + case .settingsPresets: + SettingsPresetsView() case .retroAchievements: RetroAchievementsSettingsView() case .overlay: diff --git a/platforms/ios/app/src/main/swift/Views/Settings/VirtualPadSettingsView.swift b/platforms/ios/app/src/main/swift/Views/Settings/VirtualPadSettingsView.swift index 0b57c932a4..1e3be21a44 100644 --- a/platforms/ios/app/src/main/swift/Views/Settings/VirtualPadSettingsView.swift +++ b/platforms/ios/app/src/main/swift/Views/Settings/VirtualPadSettingsView.swift @@ -5,8 +5,15 @@ import SwiftUI import UIKit import UniformTypeIdentifiers +private enum DynamicActionRole { + case aim + case fire + case holdFire +} + struct VirtualPadSettingsView: View { @State private var settings = SettingsStore.shared + @State private var dynamicSettings = DynamicThumbstickSettings.shared @State private var layoutPresets = PadLayoutPresetStore.shared @State private var skinLibrary = VPadSkinLibraryStore.shared @State private var showLayoutEditor = false @@ -194,6 +201,336 @@ struct VirtualPadSettingsView: View { } } } + + Section { + ForEach(BuiltInDynamicControlPreset.allCases) { preset in + Button { + preset.apply(settings: dynamicSettings) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(settings.localized(preset.rawValue)) + .foregroundStyle(.primary) + Text(settings.localized(preset.summary)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + Spacer() + if preset.isActive(settings: dynamicSettings) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .fixedSize() + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } header: { + Text(settings.localized("Dynamic Control Presets")) + } footer: { + Text(settings.localized("Selecting a preset changes only the listed Dynamic Control options. All sensitivity, button assignments, and other Virtual Pad settings are preserved.")) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + + Section { + Toggle( + settings.localized("Legacy Thumbsticks"), + isOn: Binding( + get: { dynamicSettings.legacyThumbsticks }, + set: { dynamicSettings.setLegacyThumbsticks($0) } + ) + ) + Toggle( + settings.localized("Dynamic Thumbsticks"), + isOn: Binding( + get: { dynamicSettings.dynamicThumbsticks }, + set: { dynamicSettings.setDynamicThumbsticks($0) } + ) + ) + Toggle(settings.localized("Swipe Camera"), isOn: $dynamicSettings.swipeCamera) + Toggle(settings.localized("Gyroscope Camera"), isOn: $dynamicSettings.gyroscopeCamera) + } header: { + Text(settings.localized("Dynamic Controls")) + } footer: { + Text(settings.localized("Legacy and Dynamic Thumbsticks are mutually exclusive. Dynamic sticks appear where each touch begins. Swipe Camera replaces the right touch stick, while Gyroscope Camera augments the active right-side control.")) + } + + controlSensitivitySection + + if dynamicSettings.dynamicThumbsticks { + thumbstickActionButtonsSection( + title: settings.localized("Action Buttons Left Thumbstick"), + toggleTitle: settings.localized("Dynamic Actions in Left Thumbstick"), + isEnabled: $dynamicSettings.leftThumbstickActionsEnabled, + aim: $dynamicSettings.leftAimButton, + fire: $dynamicSettings.leftFireButton, + holdFire: $dynamicSettings.leftHoldFireButton + ) + } + + if dynamicSettings.swipeCamera || dynamicSettings.dynamicThumbsticks { + thumbstickActionButtonsSection( + title: settings.localized("Action Buttons Right Thumbstick"), + toggleTitle: settings.localized("Dynamic Actions in Right Thumbstick"), + isEnabled: $dynamicSettings.rightThumbstickActionsEnabled, + aim: $dynamicSettings.rightAimButton, + fire: $dynamicSettings.rightFireButton, + holdFire: $dynamicSettings.rightHoldFireButton + ) + } + + Section { + Toggle( + settings.localized("Dynamic Aiming Crosshair"), + isOn: $dynamicSettings.dynamicCrosshairEnabled + ) + if dynamicSettings.dynamicCrosshairEnabled { + DynamicControlSlider( + title: settings.localized("Crosshair Size"), + value: $dynamicSettings.dynamicCrosshairSize, + range: 12...120, + step: 1, + valueLabel: { "\(Int($0)) pt" } + ) + DynamicControlSlider( + title: settings.localized("Crosshair Opacity"), + value: $dynamicSettings.dynamicCrosshairOpacity, + range: 0.10...1, + step: 0.05, + valueLabel: percentageLabel + ) + Picker( + settings.localized("Crosshair Type"), + selection: $dynamicSettings.dynamicCrosshairType + ) { + ForEach(DynamicCrosshairType.allCases) { type in + Text(settings.localized(type.title)).tag(type) + } + } + Picker( + settings.localized("Crosshair Animation"), + selection: $dynamicSettings.dynamicCrosshairAnimation + ) { + ForEach(DynamicCrosshairAnimation.allCases) { animation in + Text(settings.localized(animation.title)).tag(animation) + } + } + } + } header: { + Text(settings.localized("Dynamic Crosshair")) + } footer: { + Text(settings.localized("The crosshair appears only while Aim Mode is active. Every animation follows live swipe, thumbstick, and gyroscope direction and speed, then reacts separately to single shots and automatic fire.")) + } + + if dynamicSettings.dynamicThumbsticks { + Section { + DynamicControlSlider( + title: settings.localized("Maximum Radius"), + value: $dynamicSettings.thumbstickRadius, + range: 40...60, + step: 1, + valueLabel: { "\(Int($0)) pt" } + ) + DynamicControlSlider( + title: settings.localized("Dead Zone"), + value: $dynamicSettings.deadZone, + range: 0...0.25, + step: 0.01, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Thumbstick Opacity"), + value: $dynamicSettings.thumbstickOpacity, + range: 0...1, + step: 0.01, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Base Opacity"), + value: $dynamicSettings.baseOpacity, + range: 0...1, + step: 0.01, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Trail Opacity"), + value: $dynamicSettings.trailOpacity, + range: 0...1, + step: 0.01, + valueLabel: percentageLabel + ) + Toggle(settings.localized("Activation Haptics"), isOn: $dynamicSettings.activationHaptics) + } header: { + Text(settings.localized("Dynamic Thumbstick Feel")) + } footer: { + Text(settings.localized("The compact base stays at the initial touch point. Dead zone begins at 0% and progressively reaches the selected value as the stick moves outward. Analog output saturates at the selected radius while the nub and seven-dot trail continue following overdrag.")) + } + } + + if dynamicSettings.gyroscopeCamera { + Section { + DynamicControlSlider( + title: settings.localized("Gyro Sensitivity"), + value: $dynamicSettings.gyroSensitivity, + range: 0.5...4.0, + step: 0.1, + valueLabel: { String(format: "%.1fx", $0) } + ) + DynamicControlSlider( + title: settings.localized("Gyro Acceleration"), + value: $dynamicSettings.gyroAcceleration, + range: 0...2, + step: 0.05, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Gyro Smoothing"), + value: $dynamicSettings.gyroSmoothing, + range: 0...0.95, + step: 0.05, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Gyro Dead Zone"), + value: $dynamicSettings.gyroDeadZone, + range: 0...0.25, + step: 0.01, + valueLabel: { String(format: "%.2f rad/s", $0) } + ) + DynamicControlSlider( + title: settings.localized("Maximum Gyro Rate"), + value: $dynamicSettings.gyroMaximumRate, + range: 1...12, + step: 0.5, + valueLabel: { String(format: "%.1f rad/s", $0) } + ) + Toggle(settings.localized("Invert Gyro Horizontal"), isOn: $dynamicSettings.invertGyroHorizontal) + Toggle(settings.localized("Invert Gyro Vertical"), isOn: $dynamicSettings.invertGyroVertical) + } header: { + Text(settings.localized("Gyroscope")) + } footer: { + Text(settings.localized("Gyroscope input is active only while the virtual controller is on screen. If the sensor is unavailable, the selected touch camera continues working normally.")) + } + } + + if dynamicSettings.swipeCamera || + (dynamicSettings.dynamicThumbsticks && + (dynamicSettings.leftThumbstickActionsEnabled || dynamicSettings.rightThumbstickActionsEnabled)) { + Section { + Toggle( + dynamicActionTitle("Hold Aim While Touching Camera", role: .aim), + isOn: Binding( + get: { dynamicSettings.holdAimWhileSwipe }, + set: { dynamicSettings.setHoldAimWhileSwipe($0) } + ) + ) + Toggle( + dynamicActionTitle("Double Tap to Hold Aim", role: .aim), + isOn: Binding( + get: { dynamicSettings.doubleTapToHoldAim }, + set: { dynamicSettings.setDoubleTapToHoldAim($0) } + ) + ) + DynamicControlSlider( + title: dynamicActionTitle("Aim Release Delay", role: .aim), + value: $dynamicSettings.aimReleaseDelay, + range: 0...2, + step: 0.05, + valueLabel: durationLabel + ) + .disabled(!dynamicSettings.holdAimWhileSwipe && !dynamicSettings.doubleTapToHoldAim) + DynamicControlSlider( + title: dynamicActionTitle("Double-Tap Window", role: .aim), + value: $dynamicSettings.doubleTapWindow, + range: 0.15...0.60, + step: 0.01, + valueLabel: durationLabel + ) + .disabled(!dynamicSettings.doubleTapToHoldAim) + Toggle( + dynamicActionTitle("Tap to Fire Single Shots", role: .fire), + isOn: $dynamicSettings.tapToFire + ) + DynamicControlSlider( + title: dynamicActionTitle("Single-Shot Tap Duration", role: .fire), + value: $dynamicSettings.tapMaximumDuration, + range: 0.10...0.60, + step: 0.01, + valueLabel: durationLabel + ) + DynamicControlSlider( + title: dynamicActionTitle("Single-Shot Travel Tolerance", role: .fire), + value: $dynamicSettings.tapTravelTolerance, + range: 4...30, + step: 1, + valueLabel: { "\(Int($0)) pt" } + ) + Toggle( + dynamicActionTitle("Multiple Taps Enable Automatic Fire", role: .holdFire), + isOn: $dynamicSettings.rapidTapFireEnabled + ) + DynamicControlSlider( + title: dynamicActionTitle("Multiple-Tap Window", role: .holdFire), + value: $dynamicSettings.rapidTapWindow, + range: 0.10...0.80, + step: 0.01, + valueLabel: durationLabel + ) + .disabled(!dynamicSettings.rapidTapFireEnabled) + DynamicControlSlider( + title: dynamicActionTitle("Taps to Activate", role: .holdFire), + value: Binding( + get: { Double(dynamicSettings.rapidTapActivationCount) }, + set: { dynamicSettings.rapidTapActivationCount = Int($0.rounded()) } + ), + range: 2...5, + step: 1, + valueLabel: { "\(Int($0)) taps" } + ) + .disabled(!dynamicSettings.rapidTapFireEnabled) + DynamicControlSlider( + title: dynamicActionTitle("Automatic Fire Interval", role: .holdFire), + value: $dynamicSettings.automaticFireInterval, + range: 0.06...0.50, + step: 0.01, + valueLabel: durationLabel + ) + .disabled(!dynamicSettings.rapidTapFireEnabled) + Toggle( + dynamicActionTitle("Extend Automatic Fire While Dragging", role: .holdFire), + isOn: $dynamicSettings.extendFireWhileDragging + ) + .disabled(!dynamicSettings.rapidTapFireEnabled) + Toggle( + dynamicActionTitle("Release Fire When Touch Ends", role: .holdFire), + isOn: $dynamicSettings.releaseFireWhenTouchEnds + ) + .disabled(!dynamicSettings.rapidTapFireEnabled) + DynamicControlSlider( + title: dynamicActionTitle("Fire Release Delay", role: .holdFire), + value: $dynamicSettings.fireReleaseDelay, + range: 0...1, + step: 0.05, + valueLabel: durationLabel + ) + .disabled(!dynamicSettings.rapidTapFireEnabled || dynamicSettings.releaseFireWhenTouchEnds) + } header: { + Text(settings.localized("Dynamic Actions")) + } + } + + Section { + Button(settings.localized("Restore Dynamic Control Defaults"), role: .destructive) { + dynamicSettings.restoreDefaults() + } + } } .navigationTitle(settings.localized("Virtual Pad")) .navigationBarTitleDisplayMode(.inline) @@ -463,4 +800,203 @@ struct VirtualPadSettingsView: View { static func canonicalSkinFileName(forImportPath path: String) -> String? { VPadSkinLibraryStore.canonicalSkinFileName(forImportPath: path) } + + @ViewBuilder + private var controlSensitivitySection: some View { + Section { + DynamicControlSlider( + title: settings.localized("Movement Sensitivity"), + value: $dynamicSettings.movementSensitivity, + range: 0.33...2.0, + step: 0.01, + valueLabel: percentageLabel + ) + DynamicControlSlider( + title: settings.localized("Look Sensitivity"), + value: $dynamicSettings.lookSensitivity, + range: 0.43...1.71, + step: 0.01, + valueLabel: percentageLabel + ) + DynamicSwipeSensitivityControl( + title: settings.localized("Swipe Sensitivity"), + showsEnableToggle: false, + isEnabled: .constant(true), + value: $dynamicSettings.swipeSensitivity, + horizontalSensitivity: $dynamicSettings.swipeHorizontalSensitivity, + verticalSensitivity: $dynamicSettings.swipeVerticalSensitivity + ) + .disabled(!dynamicSettings.swipeCamera) + DynamicSwipeSensitivityControl( + title: settings.localized("Sensitivity While on Aim Mode"), + showsEnableToggle: true, + isEnabled: $dynamicSettings.swipeSensitivityWhileAimingEnabled, + value: $dynamicSettings.swipeSensitivityWhileAiming, + horizontalSensitivity: $dynamicSettings.swipeHorizontalSensitivityWhileAiming, + verticalSensitivity: $dynamicSettings.swipeVerticalSensitivityWhileAiming + ) + .disabled(!dynamicSettings.swipeCamera) + DynamicSwipeSensitivityControl( + title: settings.localized("Sensitivity While Not Aiming"), + showsEnableToggle: true, + isEnabled: $dynamicSettings.swipeSensitivityWhileNotAimingEnabled, + value: $dynamicSettings.swipeSensitivityWhileNotAiming, + horizontalSensitivity: $dynamicSettings.swipeHorizontalSensitivityWhileNotAiming, + verticalSensitivity: $dynamicSettings.swipeVerticalSensitivityWhileNotAiming + ) + .disabled(!dynamicSettings.swipeCamera) + } header: { + Text(settings.localized("Dynamic Control Sensitivity")) + } footer: { + Text(settings.localized("Each swipe profile has overall, horizontal, and vertical sensitivity. Disabled aim-state profiles fall back to Swipe Sensitivity.")) + } + } + + @ViewBuilder + private func thumbstickActionButtonsSection( + title: String, + toggleTitle: String, + isEnabled: Binding, + aim: Binding, + fire: Binding, + holdFire: Binding + ) -> some View { + Section { + Toggle(toggleTitle, isOn: isEnabled) + if isEnabled.wrappedValue { + Picker(settings.localized("Aim (Hold Thumbstick)"), selection: aim) { + ForEach(VirtualPadActionButton.allCases) { button in + Text(settings.localized(button.title)).tag(button) + } + } + Picker(settings.localized("Fire (Tap Thumbstick)"), selection: fire) { + ForEach(VirtualPadActionButton.allCases) { button in + Text(settings.localized(button.title)).tag(button) + } + } + Picker(settings.localized("Hold Fire (Fast Tap Thumbstick)"), selection: holdFire) { + ForEach(VirtualPadActionButton.allCases) { button in + Text(settings.localized(button.title)).tag(button) + } + } + } + } header: { + Text(title) + } + } + + private func dynamicActionTitle(_ baseTitle: String, role: DynamicActionRole) -> String { + var configuredButtons: [VirtualPadActionButton] = [] + if dynamicSettings.rightThumbstickActionsEnabled { + configuredButtons.append(dynamicActionButton(role: role, side: .right)) + } + if dynamicSettings.leftThumbstickActionsEnabled { + configuredButtons.append(dynamicActionButton(role: role, side: .left)) + } + if configuredButtons.isEmpty { + configuredButtons.append(dynamicActionButton(role: role, side: .right)) + } + + var seen: Set = [] + let buttonNames = configuredButtons.compactMap { button -> String? in + guard seen.insert(button).inserted else { return nil } + return settings.localized(button.title) + } + return "\(settings.localized(baseTitle)) (\(buttonNames.joined(separator: " / ")))" + } + + private func dynamicActionButton( + role: DynamicActionRole, + side: VirtualPadThumbstickSide + ) -> VirtualPadActionButton { + switch role { + case .aim: return dynamicSettings.aimButton(for: side) + case .fire: return dynamicSettings.fireButton(for: side) + case .holdFire: return dynamicSettings.holdFireButton(for: side) + } + } + + private func percentageLabel(_ value: Double) -> String { + "\(Int((value * 100).rounded()))%" + } + + private func durationLabel(_ value: Double) -> String { + String(format: "%.2f s", value) + } +} + +private struct DynamicControlSlider: View { + let title: String + @Binding var value: Double + let range: ClosedRange + let step: Double + let valueLabel: (Double) -> String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + Spacer() + Text(valueLabel(value)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $value, in: range, step: step) + } + .accessibilityElement(children: .combine) + } +} + +private struct DynamicSwipeSensitivityControl: View { + let title: String + let showsEnableToggle: Bool + @Binding var isEnabled: Bool + @Binding var value: Double + @Binding var horizontalSensitivity: Double + @Binding var verticalSensitivity: Double + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if showsEnableToggle { + Toggle(isOn: $isEnabled) { + sensitivityLabel + } + } else { + sensitivityLabel + } + Slider(value: $value, in: 0.08...0.75, step: 0.01) + .disabled(!isEnabled) + .accessibilityLabel(title) + DynamicControlSlider( + title: "Horizontal Swipe Sensitivity", + value: $horizontalSensitivity, + range: 0.25...2, + step: 0.01, + valueLabel: percentageLabel + ) + .disabled(!isEnabled) + DynamicControlSlider( + title: "Vertical Swipe Sensitivity", + value: $verticalSensitivity, + range: 0.25...2, + step: 0.01, + valueLabel: percentageLabel + ) + .disabled(!isEnabled) + } + } + + private var sensitivityLabel: some View { + HStack { + Text(title) + Spacer() + Text(String(format: "%.2f°/pt", value)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + + private func percentageLabel(_ value: Double) -> String { + "\(Int((value * 100).rounded()))%" + } } diff --git a/platforms/ios/app/src/main/swift/Views/VirtualControllerView.swift b/platforms/ios/app/src/main/swift/Views/VirtualControllerView.swift index 503be6265d..e619f65f5d 100644 --- a/platforms/ios/app/src/main/swift/Views/VirtualControllerView.swift +++ b/platforms/ios/app/src/main/swift/Views/VirtualControllerView.swift @@ -4,31 +4,70 @@ import SwiftUI import UIKit -// Singleton haptic generator — prepared once, reused for all button presses +// Lazily-created haptic generators reused for button presses and explicitly releasable +// when the gameplay UI is removed. @MainActor enum HapticManager { - static let medium: UIImpactFeedbackGenerator = { - let g = UIImpactFeedbackGenerator(style: .medium) - g.prepare() - return g - }() - static let light: UIImpactFeedbackGenerator = { - let g = UIImpactFeedbackGenerator(style: .light) - g.prepare() - return g - }() + private static var mediumGenerator: UIImpactFeedbackGenerator? + private static var lightGenerator: UIImpactFeedbackGenerator? + + static var medium: UIImpactFeedbackGenerator { + if let mediumGenerator { return mediumGenerator } + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.prepare() + mediumGenerator = generator + return generator + } + + static var light: UIImpactFeedbackGenerator { + if let lightGenerator { return lightGenerator } + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + lightGenerator = generator + return generator + } + + static func dynamicActionAim() { + guard SettingsStore.shared.hapticFeedback else { return } + light.impactOccurred(intensity: 0.55) + } + + static func dynamicActionShot() { + guard SettingsStore.shared.hapticFeedback else { return } + medium.impactOccurred(intensity: 0.75) + } + + static func dynamicActionRapidFire() { + guard SettingsStore.shared.hapticFeedback else { return } + medium.impactOccurred(intensity: 0.9) + } + + static func releaseForEmulationOnlyMode() { + mediumGenerator = nil + lightGenerator = nil + } } struct VirtualControllerView: View { @State private var settings = SettingsStore.shared + @State private var dynamicSettings = DynamicThumbstickSettings.shared @State private var skinLibrary = VPadSkinLibraryStore.shared @State private var layout = PadLayoutStore.shared + @State private var swipeInput = SwipeCameraInputDriver() + @State private var gyroscopeInput = VirtualPadGyroscopeController() + @State private var ownedTouchActionSession = VirtualPadTouchActionSession() + @State private var inputSessionGeneration = 0 var isLandscape: Bool = false var layoutSnapshot: PadLayoutSnapshot? = nil var skinDescriptor: VPadSkinDescriptor? = nil + var touchActionSession: VirtualPadTouchActionSession? = nil @State private var v2Layout: SkinManifestRuntimeLayout? = nil + private var activeTouchActionSession: VirtualPadTouchActionSession { + touchActionSession ?? ownedTouchActionSession + } + private var analogStickScale: CGFloat { min(max(CGFloat(settings.analogStickScale), 0.8), 1.6) } @@ -104,6 +143,8 @@ struct VirtualControllerView: View { .allowsHitTesting(false) } + dynamicInputZones(w: w, h: h) + ForEach(layout.controls) { control in v2ControlView(control, transform: transform, assetsDirectory: assetsDirectory, descriptor: descriptor) } @@ -150,7 +191,10 @@ struct VirtualControllerView: View { case .dpad: v2DPadView(visualRect: visual, hitRect: hit, normalPath: control.normalAssetPath, pressedPath: control.pressedAssetPath, directional: control.directional, assetsDirectory: assetsDirectory) case .thumbstick(let side, _): - v2StickView(visualRect: visual, captureDiameter: min(hit.width, hit.height), normalPath: control.normalAssetPath, knobPath: control.knobAssetPath, knobSize: knobSize, assetsDirectory: assetsDirectory, side: side) + if dynamicSettings.legacyThumbsticks && !(side == .right && dynamicSettings.swipeCamera) { + v2StickView(visualRect: visual, captureDiameter: min(hit.width, hit.height), normalPath: control.normalAssetPath, knobPath: control.knobAssetPath, knobSize: knobSize, assetsDirectory: assetsDirectory, side: side) + .id(inputSessionGeneration) + } } } @@ -224,35 +268,39 @@ struct VirtualControllerView: View { let usesFullSkin = ControllerAsset.gameplayFullSkinImage(descriptor: descriptor, isLandscape: isLandscape) != nil let v2Assets = v2AssetsDirectory - if isLandscape { - Group { - if let v2 = v2Layout, let assets = v2Assets { - v2ControllerOverlay(layout: v2, assetsDirectory: assets, descriptor: descriptor, w: geo.size.width, h: geo.size.height) - } else { - landscapeLayout(w: geo.size.width, h: geo.size.height) + ZStack { + if isLandscape { + Group { + if let v2 = v2Layout, let assets = v2Assets { + v2ControllerOverlay(layout: v2, assetsDirectory: assets, descriptor: descriptor, w: geo.size.width, h: geo.size.height) + } else { + landscapeLayout(w: geo.size.width, h: geo.size.height) + } } - } - .environment(\.padOpacity, Double(settings.padOpacity)) - .environment(\.padSkin, skin) - .environment(\.padSkinDescriptor, descriptor) - .environment(\.padUsesFullSkin, usesFullSkin) - } else { - Group { - if let v2 = v2Layout, let assets = v2Assets { - v2ControllerOverlay(layout: v2, assetsDirectory: assets, descriptor: descriptor, w: geo.size.width, h: geo.size.height) - } else { - portraitLayout(w: geo.size.width, h: geo.size.height) + .environment(\.padOpacity, Double(settings.padOpacity)) + .environment(\.padSkin, skin) + .environment(\.padSkinDescriptor, descriptor) + .environment(\.padUsesFullSkin, usesFullSkin) + } else { + Group { + if let v2 = v2Layout, let assets = v2Assets { + v2ControllerOverlay(layout: v2, assetsDirectory: assets, descriptor: descriptor, w: geo.size.width, h: geo.size.height) + } else { + portraitLayout(w: geo.size.width, h: geo.size.height) + } } + .environment(\.padOpacity, Double(settings.padOpacity)) + .environment(\.padSkin, skin) + .environment(\.padSkinDescriptor, descriptor) + .environment(\.padUsesFullSkin, usesFullSkin) } - .environment(\.padOpacity, Double(settings.padOpacity)) - .environment(\.padSkin, skin) - .environment(\.padSkinDescriptor, descriptor) - .environment(\.padUsesFullSkin, usesFullSkin) + } } // Prepare mask images before gameplay input so the first press cannot decode/scan on the hot path. .onAppear { ARMSX2VirtualPadMaskImageCache.prewarm(descriptor: effectiveSkinDescriptor) + configureAuxiliaryInputs() } .onChange(of: skinLibrary.selectedSkinID) { _, _ in ARMSX2VirtualPadMaskImageCache.prewarm(descriptor: effectiveSkinDescriptor) @@ -260,6 +308,34 @@ struct VirtualControllerView: View { .onChange(of: skinDescriptor) { _, _ in ARMSX2VirtualPadMaskImageCache.prewarm(descriptor: effectiveSkinDescriptor) } + .onChange(of: dynamicSettings.swipeCamera) { _, _ in + resetDynamicInputs() + configureAuxiliaryInputs() + } + .onChange(of: dynamicSettings.gyroscopeCamera) { _, _ in + configureAuxiliaryInputs() + } + .onChange(of: dynamicSettings.legacyThumbsticks) { _, _ in + resetDynamicInputs() + } + .onChange(of: dynamicSettings.dynamicThumbsticks) { _, _ in + resetDynamicInputs() + } + .onChange(of: dynamicSettings.leftThumbstickActionsEnabled) { _, _ in + resetDynamicInputs() + } + .onChange(of: dynamicSettings.rightThumbstickActionsEnabled) { _, _ in + resetDynamicInputs() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + cancelActiveInputSession() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + configureAuxiliaryInputs() + } + .onDisappear { + stopAuxiliaryInputs() + } .task(id: v2CacheKey) { v2Layout = SkinManifestRuntimeLayout.make( for: effectiveSkinDescriptor, @@ -420,8 +496,130 @@ struct VirtualControllerView: View { areaH: CGFloat ) -> some View { let p = pos(id, landscape: landscape) - StickView(isLeft: isLeft, sizeScale: analogStickScale, layoutScale: p.scale) - .position(x: p.x * areaW, y: p.y * areaH) + if dynamicSettings.legacyThumbsticks && !(!isLeft && dynamicSettings.swipeCamera) { + StickView(isLeft: isLeft, sizeScale: analogStickScale, layoutScale: p.scale) + .position(x: p.x * areaW, y: p.y * areaH) + .id(inputSessionGeneration) + } + } + + @ViewBuilder + private func dynamicInputZones(w: CGFloat, h: CGFloat) -> some View { + Group { + if dynamicSettings.dynamicThumbsticks && isVisible("lstick") { + dynamicThumbstickZone(isLeft: true) + .frame(width: w / 2, height: h) + .position(x: w / 4, y: h / 2) + } + + if dynamicSettings.swipeCamera && isVisible("rstick") { + VirtualPadCameraSwipeView( + maximumTapDuration: dynamicSettings.tapMaximumDuration, + tapTravelTolerance: CGFloat(dynamicSettings.tapTravelTolerance), + onDelta: { + swipeInput.add(delta: $0, isAiming: activeTouchActionSession.right.isAiming) + }, + onBegan: { + if dynamicSettings.rightThumbstickActionsEnabled { + activeTouchActionSession.right.interactionBegan() + } + }, + onActivity: { + if dynamicSettings.rightThumbstickActionsEnabled { + activeTouchActionSession.right.interactionActivity() + } + }, + onTap: { + if dynamicSettings.rightThumbstickActionsEnabled { + activeTouchActionSession.right.interactionTapped() + } + }, + onEnded: { + if dynamicSettings.rightThumbstickActionsEnabled { + activeTouchActionSession.right.interactionEnded() + } + } + ) + .frame(width: w / 2, height: h) + .position(x: w * 0.75, y: h / 2) + } else if dynamicSettings.dynamicThumbsticks && isVisible("rstick") { + dynamicThumbstickZone(isLeft: false) + .frame(width: w / 2, height: h) + .position(x: w * 0.75, y: h / 2) + } + } + .id(inputSessionGeneration) + } + + private func dynamicThumbstickZone(isLeft: Bool) -> some View { + let usesActions = isLeft + ? dynamicSettings.leftThumbstickActionsEnabled + : dynamicSettings.rightThumbstickActionsEnabled + let actionController = isLeft + ? activeTouchActionSession.left + : activeTouchActionSession.right + return DynamicThumbstickView( + isLeft: isLeft, + radius: CGFloat(dynamicSettings.thumbstickRadius), + deadZone: CGFloat(dynamicSettings.deadZone), + hapticsEnabled: dynamicSettings.activationHaptics, + thumbstickOpacity: dynamicSettings.thumbstickOpacity, + baseOpacity: dynamicSettings.baseOpacity, + trailOpacity: dynamicSettings.trailOpacity, + tapActionsEnabled: usesActions, + maximumTapDuration: dynamicSettings.tapMaximumDuration, + tapTravelTolerance: CGFloat(dynamicSettings.tapTravelTolerance), + onVector: { vector in + if isLeft { + EmulatorBridge.shared.setLeftStick(x: Float(vector.x), y: Float(vector.y)) + } else { + activeTouchActionSession.left.updateCameraMotion(vector, source: .thumbstick) + activeTouchActionSession.right.updateCameraMotion(vector, source: .thumbstick) + EmulatorBridge.shared.setRightStick(x: Float(vector.x), y: Float(vector.y)) + } + }, + onInteractionBegan: { actionController.interactionBegan() }, + onInteractionActivity: { actionController.interactionActivity() }, + onInteractionTap: { actionController.interactionTapped() }, + onInteractionEnded: { actionController.interactionEnded() } + ) + } + + private func configureAuxiliaryInputs() { + swipeInput.onCameraMotion = { motion in + activeTouchActionSession.left.updateCameraMotion(motion, source: .swipe) + activeTouchActionSession.right.updateCameraMotion(motion, source: .swipe) + } + gyroscopeInput.onCameraMotion = { motion in + activeTouchActionSession.left.updateCameraMotion(motion, source: .gyroscope) + activeTouchActionSession.right.updateCameraMotion(motion, source: .gyroscope) + } + if dynamicSettings.swipeCamera { + swipeInput.start() + } else { + swipeInput.stop() + } + gyroscopeInput.setEnabled(dynamicSettings.gyroscopeCamera) + } + + private func stopAuxiliaryInputs() { + swipeInput.stop() + gyroscopeInput.stop() + swipeInput.onCameraMotion = nil + gyroscopeInput.onCameraMotion = nil + activeTouchActionSession.reset() + EmulatorBridge.shared.resetVirtualPadAnalogInput() + } + + private func resetDynamicInputs() { + activeTouchActionSession.reset() + EmulatorBridge.shared.resetVirtualPadAnalogInput() + inputSessionGeneration &+= 1 + } + + private func cancelActiveInputSession() { + stopAuxiliaryInputs() + inputSessionGeneration &+= 1 } // MARK: - Landscape: overlay on game screen @@ -439,6 +637,8 @@ struct VirtualControllerView: View { .allowsHitTesting(false) } + dynamicInputZones(w: w, h: h) + // D-pad buttons if isVisible("dpad") { if settings.dpadDiagonalsEnabled { @@ -515,6 +715,8 @@ struct VirtualControllerView: View { .allowsHitTesting(false) } + dynamicInputZones(w: w, h: h) + GeometryReader { cGeo in let cW = cGeo.size.width let cH = cGeo.size.height