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:
Brandon
2026-07-24 22:52:00 +02:00
committed by Jeen
parent bbdfe15aaf
commit eb61611b9d
37 changed files with 5931 additions and 164 deletions
@@ -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
View File
@@ -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())
+27
View File
@@ -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