1
0
forked from MeloNX/MeloNX

Compare commits

...

10 Commits

37 changed files with 964 additions and 475 deletions

View File

@ -3,6 +3,7 @@ using ARMeilleure.CodeGen.Unwinding;
using ARMeilleure.Memory;
using ARMeilleure.Native;
using Ryujinx.Memory;
using Ryujinx.Common.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
@ -15,52 +16,73 @@ namespace ARMeilleure.Translation.Cache
static partial class JitCache
{
private static readonly int _pageSize = (int)MemoryBlock.GetPageSize();
private static readonly int _pageMask = _pageSize - 1;
private static readonly int _pageMask = _pageSize - 4;
private const int CodeAlignment = 4; // Bytes.
private const int CacheSize = 2047 * 1024 * 1024;
private const int CacheSizeIOS = 1024 * 1024 * 1024;
private const int CacheSize = 128 * 1024 * 1024;
private const int CacheSizeIOS = 128 * 1024 * 1024;
private static ReservedRegion _jitRegion;
private static JitCacheInvalidation _jitCacheInvalidator;
private static CacheMemoryAllocator _cacheAllocator;
private static List<CacheMemoryAllocator> _cacheAllocators = [];
private static readonly List<CacheEntry> _cacheEntries = new();
private static readonly object _lock = new();
private static bool _initialized;
private static readonly List<ReservedRegion> _jitRegions = new();
private static int _activeRegionIndex = 0;
[SupportedOSPlatform("windows")]
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial IntPtr FlushInstructionCache(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize);
public static void Initialize(IJitMemoryAllocator allocator)
{
if (_initialized)
{
return;
}
lock (_lock)
{
if (_initialized)
{
return;
if (OperatingSystem.IsWindows())
{
// JitUnwindWindows.RemoveFunctionTableHandler(
// _jitRegions[0].Pointer);
}
for (int i = 0; i < _jitRegions.Count; i++)
{
_jitRegions[i].Dispose();
}
_jitRegions.Clear();
_cacheAllocators.Clear();
}
else
{
_initialized = true;
}
_jitRegion = new ReservedRegion(allocator, (ulong)(OperatingSystem.IsIOS() ? CacheSizeIOS : CacheSize));
_activeRegionIndex = 0;
var firstRegion = new ReservedRegion(allocator, CacheSize);
_jitRegions.Add(firstRegion);
CacheMemoryAllocator firstCacheAllocator = new(CacheSize);
_cacheAllocators.Add(firstCacheAllocator);
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS() && !OperatingSystem.IsIOS())
{
_jitCacheInvalidator = new JitCacheInvalidation(allocator);
}
_cacheAllocator = new CacheMemoryAllocator(CacheSize);
if (OperatingSystem.IsWindows())
{
JitUnwindWindows.InstallFunctionTableHandler(_jitRegion.Pointer, CacheSize, _jitRegion.Pointer + Allocate(_pageSize));
JitUnwindWindows.InstallFunctionTableHandler(
firstRegion.Pointer, CacheSize, firstRegion.Pointer + Allocate(_pageSize)
);
}
_initialized = true;
@ -73,7 +95,9 @@ namespace ARMeilleure.Translation.Cache
{
while (_deferredRxProtect.TryDequeue(out var result))
{
ReprotectAsExecutable(result.funcOffset, result.length);
ReservedRegion targetRegion = _jitRegions[_activeRegionIndex];
ReprotectAsExecutable(targetRegion, result.funcOffset, result.length);
}
}
@ -87,7 +111,8 @@ namespace ARMeilleure.Translation.Cache
int funcOffset = Allocate(code.Length, deferProtect);
IntPtr funcPtr = _jitRegion.Pointer + funcOffset;
ReservedRegion targetRegion = _jitRegions[_activeRegionIndex];
IntPtr funcPtr = targetRegion.Pointer + funcOffset;
if (OperatingSystem.IsIOS())
{
@ -98,8 +123,7 @@ namespace ARMeilleure.Translation.Cache
}
else
{
ReprotectAsExecutable(funcOffset, code.Length);
ReprotectAsExecutable(targetRegion, funcOffset, code.Length);
JitSupportDarwinAot.Invalidate(funcPtr, (ulong)code.Length);
}
}
@ -115,9 +139,9 @@ namespace ARMeilleure.Translation.Cache
}
else
{
ReprotectAsWritable(funcOffset, code.Length);
ReprotectAsWritable(targetRegion, funcOffset, code.Length);
Marshal.Copy(code, 0, funcPtr, code.Length);
ReprotectAsExecutable(funcOffset, code.Length);
ReprotectAsExecutable(targetRegion, funcOffset, code.Length);
if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
@ -139,41 +163,50 @@ namespace ARMeilleure.Translation.Cache
{
if (OperatingSystem.IsIOS())
{
return;
// return;
}
lock (_lock)
{
Debug.Assert(_initialized);
int funcOffset = (int)(pointer.ToInt64() - _jitRegion.Pointer.ToInt64());
if (TryFind(funcOffset, out CacheEntry entry, out int entryIndex) && entry.Offset == funcOffset)
foreach (var region in _jitRegions)
{
_cacheAllocator.Free(funcOffset, AlignCodeSize(entry.Size));
_cacheEntries.RemoveAt(entryIndex);
if (pointer.ToInt64() < region.Pointer.ToInt64() ||
pointer.ToInt64() >= (region.Pointer + CacheSize).ToInt64())
{
continue;
}
int funcOffset = (int)(pointer.ToInt64() - region.Pointer.ToInt64());
if (TryFind(funcOffset, out CacheEntry entry, out int entryIndex) && entry.Offset == funcOffset)
{
_cacheAllocators[_activeRegionIndex].Free(funcOffset, AlignCodeSize(entry.Size));
_cacheEntries.RemoveAt(entryIndex);
}
return;
}
}
}
private static void ReprotectAsWritable(int offset, int size)
private static void ReprotectAsWritable(ReservedRegion region, int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
_jitRegion.Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
region.Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static void ReprotectAsExecutable(int offset, int size)
private static void ReprotectAsExecutable(ReservedRegion region, int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
_jitRegion.Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
region.Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static int Allocate(int codeSize, bool deferProtect = false)
@ -187,18 +220,33 @@ namespace ARMeilleure.Translation.Cache
alignment = 0x4000;
}
int allocOffset = _cacheAllocator.Allocate(ref codeSize, alignment);
int allocOffset = _cacheAllocators[_activeRegionIndex].Allocate(ref codeSize, alignment);
Console.WriteLine($"{allocOffset:x8}: {codeSize:x8} {alignment:x8}");
if (allocOffset < 0)
if (allocOffset >= 0)
{
throw new OutOfMemoryException("JIT Cache exhausted.");
_jitRegions[_activeRegionIndex].ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
return allocOffset;
}
_jitRegion.ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
int exhaustedRegion = _activeRegionIndex;
var newRegion = new ReservedRegion(_jitRegions[0].Allocator, CacheSize);
_jitRegions.Add(newRegion);
_activeRegionIndex = _jitRegions.Count - 1;
return allocOffset;
int newRegionNumber = _activeRegionIndex;
Logger.Info?.Print(LogClass.Cpu, $"JIT Cache Region {exhaustedRegion} exhausted, creating new Cache Region {_activeRegionIndex} ({((long)(_activeRegionIndex + 1) * CacheSize)} Total Allocation).");
_cacheAllocators.Add(new CacheMemoryAllocator(CacheSize));
int allocOffsetNew = _cacheAllocators[_activeRegionIndex].Allocate(ref codeSize, alignment);
if (allocOffsetNew < 0)
{
throw new OutOfMemoryException("Failed to allocate in new Cache Region!");
}
newRegion.ExpandIfNeeded((ulong)allocOffsetNew + (ulong)codeSize);
return allocOffsetNew;
}
private static int AlignCodeSize(int codeSize, bool deferProtect = false)
@ -251,4 +299,4 @@ namespace ARMeilleure.Translation.Cache
return false;
}
}
}
}

View File

@ -28,16 +28,10 @@
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E80AA622CD7122800029585 /* GameController.framework */; };
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA5AE812D16807500AD0B9F /* SwiftSVG */; };
CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E80AA622CD7122800029585 /* GameController.framework */; };
D1C0A55D2DBFAAD3005AB251 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = D1C0A55C2DBFAAD3005AB251 /* SwiftUIIntrospect */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
4E2953AB2D803BC9000497CD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 4E80A9852CD6F54500029585 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BD43C6212D1B248D003BBC42;
remoteInfo = com.Stossy11.MeloNX.RyujinxAg;
};
4E80A99E2CD6F54700029585 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 4E80A9852CD6F54500029585 /* Project object */;
@ -198,6 +192,7 @@
buildActionMask = 2147483647;
files = (
CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */,
D1C0A55D2DBFAAD3005AB251 /* SwiftUIIntrospect in Frameworks */,
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */,
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */,
);
@ -287,7 +282,6 @@
buildRules = (
);
dependencies = (
4E2953AC2D803BC9000497CD /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
4E80A98F2CD6F54500029585 /* MeloNX */,
@ -295,6 +289,7 @@
name = MeloNX;
packageProductDependencies = (
4EA5AE812D16807500AD0B9F /* SwiftSVG */,
D1C0A55C2DBFAAD3005AB251 /* SwiftUIIntrospect */,
);
productName = MeloNX;
productReference = 4E80A98D2CD6F54500029585 /* MeloNX.app */;
@ -386,6 +381,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */,
D1C0A55B2DBFAAD3005AB251 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
);
preferredProjectObjectVersion = 56;
productRefGroup = 4E80A98E2CD6F54500029585 /* Products */;
@ -473,12 +469,6 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4E2953AC2D803BC9000497CD /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = BD43C6212D1B248D003BBC42 /* com.Stossy11.MeloNX.RyujinxAg */;
targetProxy = 4E2953AB2D803BC9000497CD /* PBXContainerItemProxy */;
};
4E80A99F2CD6F54700029585 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 4E80A98C2CD6F54500029585 /* MeloNX */;
@ -728,6 +718,8 @@
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
);
GCC_OPTIMIZATION_LEVEL = z;
GENERATE_INFOPLIST_FILE = YES;
@ -910,6 +902,10 @@
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
);
MARKETING_VERSION = "$(VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
@ -1018,6 +1014,8 @@
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
);
GCC_OPTIMIZATION_LEVEL = z;
GENERATE_INFOPLIST_FILE = YES;
@ -1200,6 +1198,10 @@
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
);
MARKETING_VERSION = "$(VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
@ -1409,6 +1411,14 @@
kind = branch;
};
};
D1C0A55B2DBFAAD3005AB251 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/swiftui-introspect.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -1417,6 +1427,11 @@
package = 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */;
productName = SwiftSVG;
};
D1C0A55C2DBFAAD3005AB251 /* SwiftUIIntrospect */ = {
isa = XCSwiftPackageProductDependency;
package = D1C0A55B2DBFAAD3005AB251 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
productName = SwiftUIIntrospect;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 4E80A9852CD6F54500029585 /* Project object */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "fedf09a893a63378a2e53f631cd833ae83a0c9ee7338eb8d153b04fd34aaf805",
"originHash" : "9fd9e5cf42fe0cb11d840e36abe7fbfb590073df6eb786652581b3f6b11d599f",
"pins" : [
{
"identity" : "swiftsvg",
@ -9,6 +9,15 @@
"branch" : "master",
"revision" : "88b9ee086b29019e35f6f49c8e30e5552eb8fa9d"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/swiftui-introspect.git",
"state" : {
"revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
"version" : "1.3.0"
}
}
],
"version" : 3

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array/>
</plist>

View File

@ -50,7 +50,9 @@ char* installed_firmware_version();
void set_native_window(void *layerPtr);
void stop_emulation(bool shouldPause);
void pause_emulation(bool shouldPause);
void stop_emulation();
void initialize();

View File

@ -119,7 +119,7 @@ struct iOSNav<Content: View>: View {
class Ryujinx : ObservableObject {
private var isRunning = false
@Published var isRunning = false
let virtualController = VirtualController()
@ -147,6 +147,22 @@ class Ryujinx : ObservableObject {
self.games = loadGames()
}
func runloop(_ cool: @escaping () -> Void) {
if UserDefaults.standard.bool(forKey: "runOnMainThread") {
RunLoop.main.perform {
cool()
}
} else {
thread = Thread {
cool()
}
thread.qualityOfService = .userInteractive
thread.name = "MeloNX"
thread.start()
}
}
public struct Configuration : Codable, Equatable {
var gamepath: String
var inputids: [String]
@ -238,7 +254,7 @@ class Ryujinx : ObservableObject {
self.config = config
thread = Thread { [self] in
runloop { [self] in
isRunning = true
@ -299,10 +315,6 @@ class Ryujinx : ObservableObject {
}
}
}
thread.qualityOfService = .userInteractive
thread.name = "MeloNX"
thread.start()
}
func saveArrayAsTextFile(strings: [String], filePath: String) {
@ -374,10 +386,6 @@ class Ryujinx : ObservableObject {
thread.cancel()
}
var running: Bool {
return isRunning
}
func loadGames() -> [Game] {
let fileManager = FileManager.default
@ -468,6 +476,7 @@ class Ryujinx : ObservableObject {
args.append(contentsOf: ["--aspect-ratio", config.aspectRatio.rawValue])
if config.nintendoinput {
args.append("--correct-controller")
}

View File

@ -0,0 +1,27 @@
//
// ToggleButtonsState.swift
// MeloNX
//
// Created by Stossy11 on 12/04/2025.
//
struct ToggleButtonsState: Codable, Equatable {
var toggle1: Bool
var toggle2: Bool
var toggle3: Bool
var toggle4: Bool
init() {
self = .default
}
init(toggle1: Bool, toggle2: Bool, toggle3: Bool, toggle4: Bool) {
self.toggle1 = toggle1
self.toggle2 = toggle2
self.toggle3 = toggle3
self.toggle4 = toggle4
}
static let `default` = ToggleButtonsState(toggle1: false, toggle2: false, toggle3: false, toggle4: false)
}

View File

@ -0,0 +1,47 @@
//
// AppCodableStorage.swift
// MeloNX
//
// Created by Stossy11 on 12/04/2025.
//
import SwiftUI
@propertyWrapper
struct AppCodableStorage<Value: Codable & Equatable>: DynamicProperty {
@State private var value: Value
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
init(wrappedValue defaultValue: Value, _ key: String, store: UserDefaults = .standard) {
self._value = State(initialValue: {
if let data = store.data(forKey: key),
let decoded = try? JSONDecoder().decode(Value.self, from: data) {
return decoded
}
return defaultValue
}())
self.key = key
self.defaultValue = defaultValue
self.storage = store
}
var wrappedValue: Value {
get { value }
nonmutating set {
value = newValue
if let data = try? JSONEncoder().encode(newValue) {
storage.set(data, forKey: key)
}
}
}
var projectedValue: Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in self.wrappedValue = newValue }
)
}
}

View File

@ -0,0 +1,83 @@
//
// NavigationItemPalette.swift
// iTorrent
//
// Created by Daniil Vinogradov on 14.11.2024.
//
import UIKit
import SwiftUI
import SwiftUIIntrospect
public extension View {
func navitaionItemBottomPalette(@ViewBuilder body: () -> (some View)) -> some View {
modifier(NavitaionItemBottomPaletteContent(content: body().asController))
}
}
struct NavitaionItemBottomPaletteContent: ViewModifier {
let content: UIViewController
func body(content: Content) -> some View {
content
.introspect(.viewController, on: .iOS(.v14, .v15, .v16, .v17, .v18), customize: { viewController in
let view = self.content.view!
view.backgroundColor = .clear
let size = view.systemLayoutSizeFitting(.init(width: viewController.view.frame.width, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
viewController.navigationItem.setBottomPalette(view, height: size.height)
})
}
}
extension UINavigationItem {
func setBottomPalette(_ contentView: UIView?, height: CGFloat = 44) {
/// "_setBottomPalette:"
let selector = NSSelectorFromBase64String("X3NldEJvdHRvbVBhbGV0dGU6")
guard responds(to: selector) else { return }
perform(selector, with: Self.makeNavigationItemPalette(with: contentView, height: height))
}
private static func makeNavigationItemPalette(with contentView: UIView?, height: CGFloat) -> UIView? {
guard let contentView else { return nil }
contentView.translatesAutoresizingMaskIntoConstraints = false
let contentViewHolder = UIView(frame: .init(x: 0, y: 0, width: 0, height: height))
contentViewHolder.autoresizingMask = [.flexibleHeight]
contentViewHolder.addSubview(contentView)
NSLayoutConstraint.activate([
contentViewHolder.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
contentViewHolder.topAnchor.constraint(equalTo: contentView.topAnchor),
contentView.trailingAnchor.constraint(equalTo: contentViewHolder.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentViewHolder.bottomAnchor),
])
/// "_UINavigationBarPalette"
guard let paletteClass = NSClassFromBase64String("X1VJTmF2aWdhdGlvbkJhclBhbGV0dGU=") as? UIView.Type
else { return nil }
/// "alloc"
/// "initWithContentView:"
guard let palette = paletteClass.perform(NSSelectorFromBase64String("YWxsb2M="))
.takeUnretainedValue()
.perform(NSSelectorFromBase64String("aW5pdFdpdGhDb250ZW50Vmlldzo="), with: contentViewHolder)
.takeUnretainedValue() as? UIView
else { return nil }
palette.preservesSuperviewLayoutMargins = true
return palette
}
}
func NSSelectorFromBase64String(_ base64String: String) -> Selector {
NSSelectorFromString(String(base64: base64String))
}
func NSClassFromBase64String(_ aBase64ClassName: String) -> AnyClass? {
NSClassFromString(String(base64: aBase64ClassName))
}
extension String {
init(base64: String) {
self.init(data: Data(base64Encoded: base64)!, encoding: .utf8)!
}
}

View File

@ -0,0 +1,36 @@
//
// UIKitSwiftUIInarop.swift
// iTorrent
//
// Created by Daniil Vinogradov on 01/11/2023.
//
import SwiftUI
private struct GenericControllerView: UIViewControllerRepresentable {
let viewController: UIViewController
typealias UIViewControllerType = UIViewController
func makeUIViewController(context: Context) -> UIViewController {
viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) { /* Ignore */ }
}
extension View {
@MainActor
var asController: UIHostingController<Self> {
let vc = UIHostingController<Self>(rootView: self)
if #available(iOS 16.4, *) {
vc.safeAreaRegions = []
}
return vc
}
}
extension UIViewController {
var asView: some View {
GenericControllerView(viewController: self)
}
}

View File

@ -0,0 +1,125 @@
//
// FileImporter.swift
// MeloNX
//
// Created by Stossy11 on 17/04/2025.
//
import SwiftUI
import UniformTypeIdentifiers
class FileImporterManager: ObservableObject {
static let shared = FileImporterManager()
private init() {}
func importFiles(types: [UTType], allowMultiple: Bool = false, completion: @escaping (Result<[URL], Error>) -> Void) {
let id = "\(Unmanaged.passUnretained(completion as AnyObject).toOpaque())"
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .importFiles,
object: nil,
userInfo: [
"id": id,
"types": types,
"allowMultiple": allowMultiple,
"completion": completion
]
)
}
}
}
extension Notification.Name {
static let importFiles = Notification.Name("importFiles")
}
struct FileImporterView: ViewModifier {
@State private var isImporterPresented: [String: Bool] = [:]
@State private var activeImporters: [String: ImporterConfig] = [:]
struct ImporterConfig {
let types: [UTType]
let allowMultiple: Bool
let completion: (Result<[URL], Error>) -> Void
}
func body(content: Content) -> some View {
content
.background(
ForEach(Array(activeImporters.keys), id: \.self) { id in
if let config = activeImporters[id] {
FileImporterWrapper(
isPresented: Binding(
get: { isImporterPresented[id] ?? false },
set: { isImporterPresented[id] = $0 }
),
id: id,
config: config,
onCompletion: { success in
if success {
DispatchQueue.main.async {
activeImporters.removeValue(forKey: id)
}
}
}
)
}
}
)
.onReceive(NotificationCenter.default.publisher(for: .importFiles)) { notification in
guard let userInfo = notification.userInfo,
let id = userInfo["id"] as? String,
let types = userInfo["types"] as? [UTType],
let allowMultiple = userInfo["allowMultiple"] as? Bool,
let completion = userInfo["completion"] as? ((Result<[URL], Error>) -> Void) else {
return
}
let config = ImporterConfig(
types: types,
allowMultiple: allowMultiple,
completion: completion
)
activeImporters[id] = config
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isImporterPresented[id] = true
}
}
}
}
struct FileImporterWrapper: View {
@Binding var isPresented: Bool
let id: String
let config: FileImporterView.ImporterConfig
let onCompletion: (Bool) -> Void
var body: some View {
Text("wow")
.hidden()
.fileImporter(
isPresented: $isPresented,
allowedContentTypes: config.types,
allowsMultipleSelection: config.allowMultiple
) { result in
switch result {
case .success(let urls):
config.completion(.success(urls))
case .failure(let error):
config.completion(.failure(error))
}
onCompletion(true)
}
}
}
extension View {
func withFileImporter() -> some View {
self.modifier(FileImporterView())
}
}

View File

@ -70,11 +70,11 @@ struct ControllerView: View {
HStack {
ButtonView(button: .leftStick)
.padding()
ButtonView(button: .start)
ButtonView(button: .back)
}
HStack {
ButtonView(button: .back)
ButtonView(button: .start)
ButtonView(button: .rightStick)
.padding()
}
@ -257,148 +257,180 @@ struct ABXYView: View {
}
}
struct ButtonView: View {
var button: VirtualControllerButton
@State private var width: CGFloat = 45
@State private var height: CGFloat = 45
@State private var isPressed = false
@AppStorage("onscreenhandheld") var onscreenjoy: Bool = false
@Environment(\.presentationMode) var presentationMode
@AppStorage("On-ScreenControllerScale") var controllerScale: Double = 1.0
@State private var debounceTimer: Timer?
@Environment(\.presentationMode) var presentationMode
@AppCodableStorage("toggleButtons") var toggleButtons = ToggleButtonsState()
@State private var istoggle = false
@State private var isPressed = false
@State private var toggleState = false
@State private var size: CGSize = .zero
var body: some View {
Image(systemName: buttonText)
.resizable()
.scaledToFit()
.frame(width: width, height: height)
.foregroundColor(true ? Color.white.opacity(0.5) : Color.black.opacity(0.5))
Circle()
.foregroundStyle(.clear.opacity(0))
.overlay {
Image(systemName: buttonConfig.iconName)
.resizable()
.scaledToFit()
.frame(width: size.width, height: size.height)
.foregroundStyle(.white)
.opacity(isPressed ? 0.6 : 1.0)
.allowsHitTesting(false)
}
.frame(width: size.width, height: size.height)
.background(
Group {
if !button.isTrigger && button != .leftStick && button != .rightStick {
Circle()
.fill(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3))
.frame(width: width * 1.25, height: height * 1.25)
} else if button == .leftStick || button == .rightStick {
Image(systemName: buttonText)
.resizable()
.scaledToFit()
.frame(width: width * 1.25, height: height * 1.25)
.foregroundColor(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3))
} else if button.isTrigger {
Image(systemName: "" + String(turntobutton(buttonText)))
.resizable()
.scaledToFit()
.frame(width: width * 1.25, height: height * 1.25)
.foregroundColor(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3))
}
}
buttonBackground
)
.opacity(isPressed ? 0.6 : 1.0)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
handleButtonPress()
}
.onEnded { _ in
handleButtonRelease()
}
.onChanged { _ in handleButtonPress() }
.onEnded { _ in handleButtonRelease() }
)
.onAppear {
configureSizeForButton()
istoggle = (toggleButtons.toggle1 && button == .A) || (toggleButtons.toggle2 && button == .B) || (toggleButtons.toggle3 && button == .X) || (toggleButtons.toggle4 && button == .Y)
size = calculateButtonSize()
}
.onChange(of: controllerScale) { _ in
size = calculateButtonSize()
}
}
private func turntobutton(_ string: String) -> String {
var sting = string
if string.hasPrefix("zl") || string.hasPrefix("zr") {
sting = String(string.dropFirst(3))
} else {
sting = String(string.dropFirst(2))
private var buttonBackground: some View {
Group {
if !button.isTrigger && button != .leftStick && button != .rightStick {
Circle()
.fill(Color.gray.opacity(0.4))
.frame(width: size.width * 1.25, height: size.height * 1.25)
} else if button == .leftStick || button == .rightStick {
Image(systemName: buttonConfig.iconName)
.resizable()
.scaledToFit()
.frame(width: size.width * 1.25, height: size.height * 1.25)
.foregroundColor(Color.gray.opacity(0.4))
} else if button.isTrigger {
Image(systemName: convertTriggerIconToButton(buttonConfig.iconName))
.resizable()
.scaledToFit()
.frame(width: size.width * 1.25, height: size.height * 1.25)
.foregroundColor(Color.gray.opacity(0.4))
}
}
}
private func convertTriggerIconToButton(_ iconName: String) -> String {
if iconName.hasPrefix("zl") || iconName.hasPrefix("zr") {
var converted = String(iconName.dropFirst(3))
converted = converted.replacingOccurrences(of: "rectangle", with: "button")
converted = converted.replacingOccurrences(of: ".fill", with: ".horizontal.fill")
return converted
} else {
var converted = String(iconName.dropFirst(2))
converted = converted.replacingOccurrences(of: "rectangle", with: "button")
converted = converted.replacingOccurrences(of: ".fill", with: ".horizontal.fill")
return converted
}
sting = sting.replacingOccurrences(of: "rectangle", with: "button")
sting = sting.replacingOccurrences(of: ".fill", with: ".horizontal.fill")
return sting
}
private func handleButtonPress() {
if !isPressed {
guard !isPressed || istoggle else { return }
if istoggle {
toggleState.toggle()
isPressed = toggleState
let value = toggleState ? 1 : 0
Ryujinx.shared.virtualController.setButtonState(Uint8(value), for: button)
Haptics.shared.play(.medium)
} else {
isPressed = true
debounceTimer?.invalidate()
Ryujinx.shared.virtualController.setButtonState(1, for: button)
Haptics.shared.play(.medium)
}
}
private func handleButtonRelease() {
if isPressed {
isPressed = false
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false) { _ in
Ryujinx.shared.virtualController.setButtonState(0, for: button)
}
if istoggle { return }
guard isPressed else { return }
isPressed = false
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.05) {
Ryujinx.shared.virtualController.setButtonState(0, for: button)
}
}
private func configureSizeForButton() {
private func calculateButtonSize() -> CGSize {
let baseWidth: CGFloat
let baseHeight: CGFloat
if button.isTrigger {
width = 70
height = 40
baseWidth = 70
baseHeight = 40
} else if button.isSmall {
width = 35
height = 35
baseWidth = 35
baseHeight = 35
} else {
baseWidth = 45
baseHeight = 45
}
// Adjust for iPad
if UIDevice.current.systemName.contains("iPadOS") {
width *= 1.2
height *= 1.2
}
let deviceMultiplier = UIDevice.current.userInterfaceIdiom == .pad ? 1.2 : 1.0
let scaleMultiplier = CGFloat(controllerScale)
width *= CGFloat(controllerScale)
height *= CGFloat(controllerScale)
return CGSize(
width: baseWidth * deviceMultiplier * scaleMultiplier,
height: baseHeight * deviceMultiplier * scaleMultiplier
)
}
private var buttonText: String {
// Centralized button configuration
private var buttonConfig: ButtonConfiguration {
switch button {
case .A:
return "a.circle.fill"
return ButtonConfiguration(iconName: "a.circle.fill")
case .B:
return "b.circle.fill"
return ButtonConfiguration(iconName: "b.circle.fill")
case .X:
return "x.circle.fill"
return ButtonConfiguration(iconName: "x.circle.fill")
case .Y:
return "y.circle.fill"
return ButtonConfiguration(iconName: "y.circle.fill")
case .leftStick:
return "l.joystick.press.down.fill"
return ButtonConfiguration(iconName: "l.joystick.press.down.fill")
case .rightStick:
return "r.joystick.press.down.fill"
return ButtonConfiguration(iconName: "r.joystick.press.down.fill")
case .dPadUp:
return "arrowtriangle.up.circle.fill"
return ButtonConfiguration(iconName: "arrowtriangle.up.circle.fill")
case .dPadDown:
return "arrowtriangle.down.circle.fill"
return ButtonConfiguration(iconName: "arrowtriangle.down.circle.fill")
case .dPadLeft:
return "arrowtriangle.left.circle.fill"
return ButtonConfiguration(iconName: "arrowtriangle.left.circle.fill")
case .dPadRight:
return "arrowtriangle.right.circle.fill"
return ButtonConfiguration(iconName: "arrowtriangle.right.circle.fill")
case .leftTrigger:
return "zl.rectangle.roundedtop.fill"
return ButtonConfiguration(iconName: "zl.rectangle.roundedtop.fill")
case .rightTrigger:
return "zr.rectangle.roundedtop.fill"
return ButtonConfiguration(iconName: "zr.rectangle.roundedtop.fill")
case .leftShoulder:
return "l.rectangle.roundedbottom.fill"
return ButtonConfiguration(iconName: "l.rectangle.roundedbottom.fill")
case .rightShoulder:
return "r.rectangle.roundedbottom.fill"
return ButtonConfiguration(iconName: "r.rectangle.roundedbottom.fill")
case .start:
return "plus.circle.fill"
return ButtonConfiguration(iconName: "plus.circle.fill")
case .back:
return "minus.circle.fill"
return ButtonConfiguration(iconName: "minus.circle.fill")
case .guide:
return "house.circle.fill"
return ButtonConfiguration(iconName: "house.circle.fill")
}
}
struct ButtonConfiguration {
let iconName: String
}
}

View File

@ -65,28 +65,27 @@ struct EmulationView: View {
Spacer()
}
Spacer()
if ssb {
HStack {
Button {
if let screenshot = Ryujinx.shared.emulationUIView?.screenshot() {
UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil)
Image(systemName: "arrow.left.circle")
.resizable()
.frame(width: 50, height: 50)
.onTapGesture {
startgame = nil
stop_emulation()
try? Ryujinx.shared.stop()
}
} label: {
Image(systemName: "square.and.arrow.up")
}
.frame(width: UIDevice.current.systemName.contains("iPadOS") ? 60 * 1.2 : 45, height: UIDevice.current.systemName.contains("iPadOS") ? 60 * 1.2 : 45)
.padding()
.padding()
Spacer()
}
}
Spacer()
}
}
}
@ -102,13 +101,13 @@ struct EmulationView: View {
.onChange(of: scenePhase) { newPhase in
// Detect when the app enters the background
if newPhase == .background {
stop_emulation(true)
pause_emulation(true)
isInBackground = true
} else if newPhase == .active {
stop_emulation(false)
pause_emulation(false)
isInBackground = false
} else if newPhase == .inactive {
stop_emulation(true)
pause_emulation(true)
isInBackground = true
}
}

View File

@ -87,40 +87,48 @@ class MeloMTKView: MTKView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
let disabled = UserDefaults.standard.bool(forKey: "disableTouch")
setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9)
for touch in touches {
let location = touch.location(in: self)
if scaleToTargetResolution(location) == nil {
ignoredTouches.insert(touch)
continue
if !disabled {
for touch in touches {
let location = touch.location(in: self)
if scaleToTargetResolution(location) == nil {
ignoredTouches.insert(touch)
continue
}
activeTouches.append(touch)
let index = activeTouches.firstIndex(of: touch)!
let scaledLocation = scaleToTargetResolution(location)!
// // print("Touch began at: \(scaledLocation) and \(self.aspectRatio)")
touch_began(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index))
}
activeTouches.append(touch)
let index = activeTouches.firstIndex(of: touch)!
let scaledLocation = scaleToTargetResolution(location)!
// // print("Touch began at: \(scaledLocation) and \(self.aspectRatio)")
touch_began(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index))
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
let disabled = UserDefaults.standard.bool(forKey: "disableTouch")
setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9)
for touch in touches {
if ignoredTouches.contains(touch) {
ignoredTouches.remove(touch)
continue
}
if let index = activeTouches.firstIndex(of: touch) {
activeTouches.remove(at: index)
if !disabled {
for touch in touches {
if ignoredTouches.contains(touch) {
ignoredTouches.remove(touch)
continue
}
// // print("Touch ended for index \(index)")
touch_ended(Int32(index))
if let index = activeTouches.firstIndex(of: touch) {
activeTouches.remove(at: index)
// // print("Touch ended for index \(index)")
touch_ended(Int32(index))
}
}
}
}
@ -128,26 +136,30 @@ class MeloMTKView: MTKView {
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
let disabled = UserDefaults.standard.bool(forKey: "disableTouch")
setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9)
for touch in touches {
if ignoredTouches.contains(touch) {
continue
}
let location = touch.location(in: self)
guard let scaledLocation = scaleToTargetResolution(location) else {
if let index = activeTouches.firstIndex(of: touch) {
activeTouches.remove(at: index)
// // print("Touch left active area, removed index \(index)")
touch_ended(Int32(index))
if !disabled {
for touch in touches {
if ignoredTouches.contains(touch) {
continue
}
let location = touch.location(in: self)
guard let scaledLocation = scaleToTargetResolution(location) else {
if let index = activeTouches.firstIndex(of: touch) {
activeTouches.remove(at: index)
// // print("Touch left active area, removed index \(index)")
touch_ended(Int32(index))
}
continue
}
if let index = activeTouches.firstIndex(of: touch) {
// // print("Touch moved to: \(scaledLocation)")
touch_moved(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index))
}
continue
}
if let index = activeTouches.firstIndex(of: touch) {
// // print("Touch moved to: \(scaledLocation)")
touch_moved(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index))
}
}
}

View File

@ -45,7 +45,7 @@ struct ContentView: View {
@AppStorage("quit") var quit: Bool = false
@State var quits: Bool = false
@AppStorage("MVK_CONFIG_PREFILL_METAL_COMMAND_BUFFERS") var mVKPreFillBuffer: Bool = true
@AppStorage("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS") var syncqsubmits: Bool = true
@AppStorage("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS") var syncqsubmits: Bool = false
@AppStorage("ignoreJIT") var ignoreJIT: Bool = false
// Loading Animation
@ -302,8 +302,6 @@ struct ContentView: View {
}
private func setupEmulation() {
refreshControllersList()
isVCA = (currentControllers.first(where: { $0 == onscreencontroller }) != nil)
DispatchQueue.main.async {
@ -321,34 +319,14 @@ struct ContentView: View {
controllersList.removeAll(where: { $0.id == "0" || (!$0.name.starts(with: "GC - ") && $0 != onscreencontroller) })
controllersList.mutableForEach { $0.name = $0.name.replacingOccurrences(of: "GC - ", with: "") }
currentControllers = []
if !currentControllers.isEmpty, !(currentControllers.count == 1) {
var currentController: [Controller] = []
if currentController.count == 1 {
currentController.append(controllersList[0])
} else if (controllersList.count - 1) >= 1 {
for controller in controllersList {
if controller.id != onscreencontroller.id && !currentControllers.contains(where: { $0.id == controller.id }) {
currentController.append(controller)
}
}
}
if currentController == currentControllers {
currentControllers = []
currentControllers = currentController
}
} else {
currentControllers = []
if controllersList.count == 1 {
currentControllers.append(controllersList[0])
} else if (controllersList.count - 1) >= 1 {
for controller in controllersList {
if controller.id != onscreencontroller.id && !currentControllers.contains(where: { $0.id == controller.id }) {
currentControllers.append(controller)
}
if controllersList.count == 1 {
currentControllers.append(controllersList[0])
} else if (controllersList.count - 1) >= 1 {
for controller in controllersList {
if controller.id != onscreencontroller.id && !currentControllers.contains(where: { $0.id == controller.id }) {
currentControllers.append(controller)
}
}
}
@ -412,6 +390,7 @@ struct ContentView: View {
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.host == "game" {
DispatchQueue.main.async {
refreshControllersList()
if let text = components.queryItems?.first(where: { $0.name == "id" })?.value {
game = ryujinx.games.first(where: { $0.titleId == text })
} else if let text = components.queryItems?.first(where: { $0.name == "name" })?.value {

View File

@ -96,8 +96,8 @@ struct GameInfoSheet: View {
.navigationTitle(game.titleName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
ToolbarItem(placement: .cancellationAction) {
Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}

View File

@ -61,31 +61,39 @@ struct GameLibraryView: View {
var body: some View {
iOSNav {
ZStack {
// Background color
Color(UIColor.systemBackground)
.ignoresSafeArea()
VStack(spacing: 0) {
// Header with stats
if !Ryujinx.shared.games.isEmpty {
GameLibraryHeader(
totalGames: Ryujinx.shared.games.count,
recentGames: realRecentGames.count,
firmwareVersion: firmwareversion
)
}
// Game list
if Ryujinx.shared.games.isEmpty {
EmptyGameLibraryView(
isSelectingGameFile: $isSelectingGameFile,
isImporting: $isImporting
)
} else {
gameListView
.animation(.easeInOut(duration: 0.3), value: searchText)
}
VStack(spacing: 0) {
// Game list
if Ryujinx.shared.games.isEmpty {
EmptyGameLibraryView(isSelectingGameFile: $isSelectingGameFile)
} else {
gameListView
.animation(.easeInOut(duration: 0.3), value: searchText)
}
}
.navitaionItemBottomPalette {
// Header with stats
if !Ryujinx.shared.games.isEmpty {
GameLibraryHeader(
totalGames: Ryujinx.shared.games.count,
recentGames: realRecentGames.count,
firmwareVersion: firmwareversion
)
.overlay(Group {
if ryujinx.jitenabled {
VStack {
HStack {
Spacer()
Circle()
.frame(width: 12, height: 12)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.foregroundColor(Color.green)
.padding()
}
Spacer()
}
}
})
}
}
.navigationTitle("Game Library")
@ -150,35 +158,37 @@ struct GameLibraryView: View {
}
}
}
.overlay(Group {
if ryujinx.jitenabled {
VStack {
HStack {
Spacer()
Circle()
.frame(width: 12, height: 12)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.foregroundColor(Color.green)
.padding()
}
Spacer()
}
}
})
.onChange(of: startemu) { game in
guard let game else { return }
addToRecentGames(game)
}
// .searchable(text: $searchText, placement: .toolbar, prompt: "Search games or developers")
.searchable(text: $searchText, placement: .toolbar, prompt: "Search games or developers")
.onChange(of: searchText) { _ in
isSearching = !searchText.isEmpty
}
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.folder, .nsp, .xci, .zip, .item]) { result in
handleFileImport(result: result)
.onChange(of: isImporting) { newValue in
if newValue {
FileImporterManager.shared.importFiles(types: [.nsp, .xci, .item]) { result in
isImporting = false
handleRunningGame(result: result)
}
}
}
.fileImporter(isPresented: $firmwareInstaller, allowedContentTypes: [.item]) { result in
handleFirmwareImport(result: result)
.onChange(of: isSelectingGameFile) { newValue in
if newValue {
FileImporterManager.shared.importFiles(types: [.nsp, .xci, .item]) { result in
isImporting = false
handleAddingGame(result: result)
}
}
}
.onChange(of: firmwareInstaller) { newValue in
if newValue {
FileImporterManager.shared.importFiles(types: [.folder, .zip]) { result in
isImporting = false
handleFirmwareImport(result: result)
}
}
}
.sheet(isPresented: $isSelectingGameUpdate) {
UpdateManagerSheet(game: $gameInfo)
@ -239,26 +249,28 @@ struct GameLibraryView: View {
}
// Library Section
VStack(alignment: .leading) {
Text("Library")
.font(.headline)
.foregroundColor(.primary)
.padding(.horizontal)
.padding(.top)
ForEach(filteredGames) { game in
GameListRow(
game: game,
startemu: $startemu,
games: games,
isViewingGameInfo: $isViewingGameInfo,
isSelectingGameUpdate: $isSelectingGameUpdate,
isSelectingGameDLC: $isSelectingGameDLC,
gameRequirements: $gameRequirements,
gameInfo: $gameInfo
)
.padding(.horizontal, 3)
.padding(.vertical, 8)
if !filteredGames.isEmpty {
VStack(alignment: .leading) {
Text("Library")
.font(.headline)
.foregroundColor(.primary)
.padding(.horizontal)
.padding(.top)
ForEach(filteredGames) { game in
GameListRow(
game: game,
startemu: $startemu,
games: games,
isViewingGameInfo: $isViewingGameInfo,
isSelectingGameUpdate: $isSelectingGameUpdate,
isSelectingGameDLC: $isSelectingGameDLC,
gameRequirements: $gameRequirements,
gameInfo: $gameInfo
)
.padding(.horizontal, 3)
.padding(.vertical, 8)
}
}
}
} else {
@ -273,7 +285,7 @@ struct GameLibraryView: View {
gameRequirements: $gameRequirements,
gameInfo: $gameInfo
)
.padding(.horizontal, 3)
.padding(.horizontal)
.padding(.vertical, 8)
}
}
@ -359,68 +371,72 @@ struct GameLibraryView: View {
// MARK: - Import Handlers
private func handleFileImport(result: Result<URL, Error>) {
if isSelectingGameFile {
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
// print("Failed to access security-scoped resource")
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let romsDirectory = documentsDirectory.appendingPathComponent("roms")
if !fileManager.fileExists(atPath: romsDirectory.path) {
try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil)
}
let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent)
try fileManager.copyItem(at: url, to: destinationURL)
Ryujinx.shared.games = Ryujinx.shared.loadGames()
} catch {
// print("Error copying game file: \(error)")
}
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
private func handleAddingGame(result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first, url.startAccessingSecurityScopedResource() else {
// print("Failed to access security-scoped resource")
return
}
} else {
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
// print("Failed to access security-scoped resource")
return
defer { url.stopAccessingSecurityScopedResource() }
do {
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let romsDirectory = documentsDirectory.appendingPathComponent("roms")
if !fileManager.fileExists(atPath: romsDirectory.path) {
try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil)
}
do {
let handle = try FileHandle(forReadingFrom: url)
let fileExtension = (url.pathExtension as NSString).utf8String
let extensionPtr = UnsafeMutablePointer<CChar>(mutating: fileExtension)
let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr)
let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url)
DispatchQueue.main.async {
startemu = game
}
} catch {
// print(error)
}
let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent)
try fileManager.copyItem(at: url, to: destinationURL)
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
Ryujinx.shared.games = Ryujinx.shared.loadGames()
} catch {
// print("Error copying game file: \(error)")
}
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
}
}
private func handleFirmwareImport(result: Result<URL, Error>) {
private func handleRunningGame(result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first, url.startAccessingSecurityScopedResource() else {
// print("Failed to access security-scoped resource")
return
}
do {
let handle = try FileHandle(forReadingFrom: url)
let fileExtension = (url.pathExtension as NSString).utf8String
let extensionPtr = UnsafeMutablePointer<CChar>(mutating: fileExtension)
let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr)
let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url)
DispatchQueue.main.async {
startemu = game
}
} catch {
// print(error)
}
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
}
}
private func handleFirmwareImport(result: Result<[URL], Error>) {
switch result {
case .success(let url):
guard let url = url.first else {
return
}
do {
let fun = url.startAccessingSecurityScopedResource()
let path = url.path
@ -525,7 +541,6 @@ extension Game: Codable {
// MARK: - Empty Library View
struct EmptyGameLibraryView: View {
@Binding var isSelectingGameFile: Bool
@Binding var isImporting: Bool
var body: some View {
VStack(spacing: 24) {
@ -548,7 +563,6 @@ struct EmptyGameLibraryView: View {
Button {
isSelectingGameFile = true
isImporting = true
} label: {
Label("Add Game", systemImage: "plus")
.font(.headline)
@ -577,7 +591,7 @@ struct GameLibraryHeader: View {
// Stats cards
StatCard(
icon: "gamecontroller.fill",
title: "Total Games",
title: "Games",
value: "\(totalGames)",
color: .blue
)
@ -597,8 +611,7 @@ struct GameLibraryHeader: View {
)
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.padding(.bottom, 8)
}
}
@ -834,7 +847,7 @@ struct GameListRow: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(width: .infinity, height: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.contentShape(Rectangle())
.contextMenu {
@ -1127,7 +1140,7 @@ func pullGameCompatibility(completion: @escaping (Result<[GameRequirements], Err
return
}
guard let url = URL(string: "https://melonx.org/api/game_entries") else {
guard let url = URL(string: "https://melonx.net/api/game_entries") else {
completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil)))
return
}

View File

@ -56,6 +56,15 @@ struct SettingsView: View {
@AppStorage("HideButtons") var hideButtonsJoy = false
@AppStorage("checkForUpdate") var checkForUpdate: Bool = true
@AppStorage("disableTouch") var disableTouch = false
@AppStorage("runOnMainThread") var runOnMainThread = false
@AppCodableStorage("toggleButtons") var toggleButtons = ToggleButtonsState()
@State private var showResolutionInfo = false
@State private var showAnisotropicInfo = false
@State private var showControllerInfo = false
@ -93,59 +102,59 @@ struct SettingsView: View {
}
}
var appVersion: String {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else {
return "Unknown"
}
return version
}
@FocusState private var isArgumentsKeyboardVisible: Bool
var body: some View {
iOSNav {
ZStack {
// Background color
Color(UIColor.systemBackground)
.ignoresSafeArea()
VStack(spacing: 0) {
// Category selector
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(SettingsCategory.allCases, id: \.id) { category in
CategoryButton(
title: category.rawValue,
icon: category.icon,
isSelected: selectedCategory == category
) {
selectedCategory = category
}
}
}
ScrollView {
VStack(spacing: 24) {
// Device Info Card
deviceInfoCard
.padding(.horizontal)
.padding(.vertical, 8)
}
Divider()
// Settings content
ScrollView {
VStack(spacing: 24) {
// Device Info Card
deviceInfoCard
.padding(.horizontal)
.padding(.top)
switch selectedCategory {
case .graphics:
graphicsSettings
case .input:
inputSettings
case .system:
systemSettings
case .advanced:
advancedSettings
case .misc:
miscSettings
}
Spacer(minLength: 50)
}
.padding(.bottom)
.padding(.top)
switch selectedCategory {
case .graphics:
graphicsSettings
case .input:
inputSettings
case .system:
systemSettings
case .advanced:
advancedSettings
case .misc:
miscSettings
}
}
.padding(.bottom)
}
.scrollDismissesKeyboardIfAvailable()
.navitaionItemBottomPalette {
// Category selector
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(SettingsCategory.allCases, id: \.id) { category in
CategoryButton(
title: category.rawValue,
icon: category.icon,
isSelected: selectedCategory == category
) {
selectedCategory = category
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
}
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
.defaultScrollAnchorIsAvailable(.center)
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
@ -189,6 +198,14 @@ struct SettingsView: View {
Text("\(memoryText) RAM")
.font(.subheadline)
.foregroundColor(.secondary)
Text("·")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Version \(appVersion)")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Device cards
@ -460,6 +477,16 @@ struct SettingsView: View {
Divider()
SettingsToggle(isOn: $swapBandA, icon: "rectangle.2.swap", label: "Swap Face Buttons (Physical Controller)")
Divider()
DisclosureGroup("Toggle Buttons") {
SettingsToggle(isOn: $toggleButtons.toggle1, icon: "circle.grid.cross.right.filled", label: "Toggle A")
SettingsToggle(isOn: $toggleButtons.toggle2, icon: "circle.grid.cross.down.filled", label: "Toggle B")
SettingsToggle(isOn: $toggleButtons.toggle3, icon: "circle.grid.cross.up.filled", label: "Toggle X")
SettingsToggle(isOn: $toggleButtons.toggle4, icon: "circle.grid.cross.left.filled", label: "Toggle Y")
}
.padding(.vertical, 6)
}
}
@ -707,6 +734,10 @@ struct SettingsView: View {
// Advanced toggles card
SettingsCard {
VStack(spacing: 4) {
SettingsToggle(isOn: $runOnMainThread, icon: "square.stack.3d.up", label: "Run Core on Main Thread")
Divider()
SettingsToggle(isOn: $config.dfsIntegrityChecks, icon: "checkmark.shield", label: "Disable FS Integrity Checks")
Divider()
@ -747,7 +778,7 @@ struct SettingsView: View {
.foregroundColor(.primary)
if #available(iOS 15.0, *) {
TextField("Separate arguments with commas", text: Binding(
TextField("Separate arguments with commas" ,text: Binding(
get: {
config.additionalArgs.joined(separator: ", ")
},
@ -762,6 +793,14 @@ struct SettingsView: View {
.textInputAutocapitalization(.none)
.disableAutocorrection(true)
.padding(.vertical, 4)
.toolbar {
ToolbarItem(placement: .keyboard) {
Button("Dismiss") {
isArgumentsKeyboardVisible = false
}
}
}
.focused($isArgumentsKeyboardVisible)
} else {
TextField("Separate arguments with commas", text: Binding(
get: {
@ -809,8 +848,13 @@ struct SettingsView: View {
SettingsSection(title: "Miscellaneous Options") {
SettingsCard {
VStack(spacing: 4) {
// Disable Touch card
SettingsToggle(isOn: $disableTouch, icon: "rectangle.and.hand.point.up.left.filled", label: "Disable Touch")
Divider()
// Screenshot button card
SettingsToggle(isOn: $ssb, icon: "square.and.arrow.up", label: "Screenshot Button")
SettingsToggle(isOn: $ssb, icon: "arrow.left.circle", label: "Exit Button")
Divider()
@ -861,6 +905,9 @@ struct SettingsView: View {
}
}
Divider()
SettingsToggle(isOn: $checkForUpdate, icon: "square.and.arrow.down", label: "Check for Updates")
if ryujinx.firmwareversion != "0" {
Divider()
@ -1013,6 +1060,7 @@ struct CategoryButton: View {
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? Color.blue.opacity(0.15) : Color.clear)
)
.animation(.bouncy(duration: 0.3), value: isSelected)
}
}
}
@ -1122,3 +1170,28 @@ struct InfoCard: View {
.cornerRadius(8)
}
}
// this code is used to enable the keyboard to be dismissed when scrolling if available on iOS 16+
extension View {
@ViewBuilder
func scrollDismissesKeyboardIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}
// this code is used to enable the keyboard to be dismissed when scrolling if available on iOS 16+
extension View {
@ViewBuilder
func defaultScrollAnchorIsAvailable(_ anchor: UnitPoint?) -> some View {
if #available(iOS 17.0, *) {
self.defaultScrollAnchor(anchor)
} else {
self
}
}
}

View File

@ -32,13 +32,21 @@ struct MeloNXApp: App {
@AppStorage("hasbeenfinished") var finishedStorage: Bool = false
@AppStorage("location-enabled") var locationenabled: Bool = false
@AppStorage("checkForUpdate") var checkForUpdate: Bool = true
@AppStorage("runOnMainThread") var runOnMainThread = false
@AppStorage("autoJIT") var autoJIT = false
var body: some Scene {
WindowGroup {
if finishedStorage {
ContentView()
.withFileImporter()
.onAppear {
checkLatestVersion()
if checkForUpdate {
checkLatestVersion()
}
}
.sheet(isPresented: Binding(
get: { showOutOfDateSheet && updateInfo != nil },
@ -56,10 +64,8 @@ struct MeloNXApp: App {
} else {
SetupView(finished: $finished)
.onChange(of: finished) { newValue in
withAnimation {
withAnimation {
finishedStorage = newValue
}
withAnimation(.easeOut) {
finishedStorage = newValue
}
}
}
@ -73,7 +79,7 @@ struct MeloNXApp: App {
#if DEBUG
let urlString = "http://192.168.178.116:8000/api/latest_release"
#else
let urlString = "https://melonx.org/api/latest_release"
let urlString = "https://melonx.net/api/latest_release"
#endif
guard let url = URL(string: urlString) else {

View File

@ -286,16 +286,6 @@ namespace Ryujinx.Headless.SDL2
{
_contentManager = new ContentManager(_virtualFileSystem);
}
if (_accountManager == null)
{
_accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, "");
}
if (_userChannelPersistence == null)
{
_userChannelPersistence = new UserChannelPersistence();
}
}
static void Main(string[] args)
@ -402,8 +392,8 @@ namespace Ryujinx.Headless.SDL2
return String.Empty;
}
[UnmanagedCallersOnly(EntryPoint = "stop_emulation")]
public static void StopEmulation(bool shouldPause)
[UnmanagedCallersOnly(EntryPoint = "pause_emulation")]
public static void PauseEmulation(bool shouldPause)
{
if (_window != null)
{
@ -422,6 +412,15 @@ namespace Ryujinx.Headless.SDL2
}
}
[UnmanagedCallersOnly(EntryPoint = "stop_emulation")]
public static void StopEmulation()
{
if (_window != null)
{
_window.Exit();
}
}
[UnmanagedCallersOnly(EntryPoint = "get_game_info")]
public static GameInfoNative GetGameInfoNative(int descriptor, IntPtr extensionPtr)
{
@ -1134,42 +1133,22 @@ namespace Ryujinx.Headless.SDL2
static void Load(Options option)
{
_libHacHorizonManager = new LibHacHorizonManager();
_libHacHorizonManager.InitializeFsServer(_virtualFileSystem);
_libHacHorizonManager.InitializeArpServer();
_libHacHorizonManager.InitializeBcatServer();
_libHacHorizonManager.InitializeSystemClients();
if (_virtualFileSystem == null)
_contentManager = new ContentManager(_virtualFileSystem);
_accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile);
_userChannelPersistence = new UserChannelPersistence();
_inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver());
if (OperatingSystem.IsIOS())
{
_virtualFileSystem = VirtualFileSystem.CreateInstance();
}
if (_libHacHorizonManager == null)
{
_libHacHorizonManager = new LibHacHorizonManager();
_libHacHorizonManager.InitializeFsServer(_virtualFileSystem);
_libHacHorizonManager.InitializeArpServer();
_libHacHorizonManager.InitializeBcatServer();
_libHacHorizonManager.InitializeSystemClients();
}
if (_contentManager == null)
{
_contentManager = new ContentManager(_virtualFileSystem);
}
if (_accountManager == null)
{
_accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile);
}
if (_userChannelPersistence == null)
{
_userChannelPersistence = new UserChannelPersistence();
}
if (_inputManager == null)
{
_inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver());
}
if (OperatingSystem.IsIOS()) {
Logger.Info?.Print(LogClass.Application, $"Current Device: {option.DisplayName} ({option.DeviceModel}) {Environment.OSVersion.Version}");
Logger.Info?.Print(LogClass.Application, $"Increased Memory Limit: {option.MemoryEnt}");
}