mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
iOS: JIT Keep Alive fixes, Dynamic Backgrounds and UI performance improvements, New features Dynamic Controls, presets, efficient background lifecycle and Emulation-Only Mode.
iOS: JIT Keep Alive fixes, Dynamic Backgrounds and UI performance improvements, New features Dynamic Controls, presets, efficient background lifecycle and Emulation-Only Mode.
This commit is contained in:
@@ -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
|
||||
|
||||
+143
-5
@@ -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<VMState> s_state{VMState::Shutdown};
|
||||
static std::atomic_bool s_emulation_only_mode{false};
|
||||
static std::atomic<u32> 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<std::thread> 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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ typedef void (^ARMSX2RetroAchievementsCompletion)(BOOL success, NSString * _Nonn
|
||||
+ (nonnull NSString *)buildVersion;
|
||||
+ (BOOL)isJITAvailable;
|
||||
+ (BOOL)isNoJITFallbackActive;
|
||||
+ (BOOL)isIdleVMPrewarmResolved;
|
||||
+ (nonnull NSArray<NSURL *> *)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<NSString *, id> *)deviceStatsForAccessibility;
|
||||
|
||||
|
||||
@@ -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<NSURL *> *)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<u32>(releaseFlags));
|
||||
}, false);
|
||||
}
|
||||
|
||||
// Apply OSD preset — sets ALL GSConfig flags to match the preset
|
||||
+ (void)applyOsdPreset:(int)preset {
|
||||
// Clear everything first
|
||||
|
||||
@@ -620,6 +620,28 @@ static std::atomic<bool> s_jitExpired{false};
|
||||
// when revalidation decides to tear the old thread down and create a new one.
|
||||
static std::atomic<bool> s_vmInitComplete{false};
|
||||
static std::atomic<bool> s_vmThreadShouldExit{false};
|
||||
static std::atomic<bool> 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<std::mutex> 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 {
|
||||
|
||||
@@ -330,6 +330,8 @@
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ARMSX2 uses local network access for PS2 online play through DEV9 sockets.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>ARMSX2 uses device motion to control the emulated right analog stick when Gyroscope Camera is enabled.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
@@ -416,6 +418,25 @@
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.armsx2.settings-preset</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>ARMSX2 Settings Preset</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>ini</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>text/plain</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.armsx2.skin</string>
|
||||
|
||||
@@ -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<bool> s_captureMode{false};
|
||||
std::atomic<int> s_capturedButton{-1};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[..<separator]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(line[line.index(after: separator)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
storage[Self.storageKey(section: section, key: key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
func value(section: String, key: String) -> 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<Int>) 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())"
|
||||
}
|
||||
}
|
||||
@@ -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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Bool>(
|
||||
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<Int>(
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user