Rewrite Display Code and more

This commit is contained in:
Stossy11 2025-03-06 11:57:03 +11:00
parent e372f6eb35
commit 8ca88def54
40 changed files with 1804 additions and 582 deletions

View File

@ -25,7 +25,7 @@ namespace ARMeilleure.Translation.Cache
private static ReservedRegion _jitRegion;
private static JitCacheInvalidation _jitCacheInvalidator;
private static CacheMemoryAllocator _cacheAllocator;
private static List<CacheMemoryAllocator> _cacheAllocators = [];
private static readonly List<CacheEntry> _cacheEntries = new();
@ -42,31 +42,42 @@ namespace ARMeilleure.Translation.Cache
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;
}
var firstRegion = new ReservedRegion(allocator, CacheSize);
_jitRegions.Add(firstRegion);
_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(
@ -162,7 +173,7 @@ namespace ARMeilleure.Translation.Cache
if (TryFind(funcOffset, out CacheEntry entry, out int entryIndex) && entry.Offset == funcOffset)
{
_cacheAllocator.Free(funcOffset, AlignCodeSize(entry.Size));
_cacheAllocators[_activeRegionIndex].Free(funcOffset, AlignCodeSize(entry.Size));
_cacheEntries.RemoveAt(entryIndex);
}
@ -202,16 +213,12 @@ namespace ARMeilleure.Translation.Cache
alignment = 0x4000;
}
for (int i = _activeRegionIndex; i < _jitRegions.Count; i++)
{
int allocOffset = _cacheAllocator.Allocate(ref codeSize, alignment);
int allocOffset = _cacheAllocators[_activeRegionIndex].Allocate(ref codeSize, alignment);
if (allocOffset >= 0)
{
_jitRegions[i].ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
_activeRegionIndex = i;
return allocOffset;
}
if (allocOffset >= 0)
{
_jitRegions[_activeRegionIndex].ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
return allocOffset;
}
int exhaustedRegion = _activeRegionIndex;
@ -221,9 +228,11 @@ namespace ARMeilleure.Translation.Cache
int newRegionNumber = _activeRegionIndex;
_cacheAllocator = new CacheMemoryAllocator(CacheSize);
Logger.Info?.Print(LogClass.Cpu, $"JIT Cache Region {exhaustedRegion} exhausted, creating new Cache Region {_activeRegionIndex} ({((long)(_activeRegionIndex + 1) * CacheSize)} Total Allocation).");
int allocOffsetNew = _cacheAllocator.Allocate(ref codeSize, alignment);
_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!");

View File

@ -674,6 +674,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",
);
GCC_OPTIMIZATION_LEVEL = fast;
GENERATE_INFOPLIST_FILE = YES;
@ -761,6 +765,14 @@
"$(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",
"$(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 = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
@ -822,6 +834,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",
);
GCC_OPTIMIZATION_LEVEL = fast;
GENERATE_INFOPLIST_FILE = YES;
@ -909,6 +925,14 @@
"$(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",
"$(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 = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;

View File

@ -48,13 +48,21 @@ void install_firmware(const char* inputPtr);
char* installed_firmware_version();
void set_native_window(void *layerPtr);
void stop_emulation();
void initialize();
int main_ryujinx_sdl(int argc, char **argv);
int get_current_fps();
void initialize();
void touch_began(float x, float y, int index);
void touch_moved(float x, float y, int index);
void touch_ended(int index);
#ifdef __cplusplus
}

View File

@ -7,6 +7,19 @@
import Foundation
@_silgen_name("csops")
func csops(pid: Int32, ops: Int32, useraddr: UnsafeMutableRawPointer?, usersize: Int32) -> Int32
func isJITEnabled() -> Bool {
var flags: Int = 0
if checkAppEntitlement("dynamic-codesigning") {
return allocateTest()
}
return csops(pid: getpid(), ops: 0, useraddr: &flags, usersize: Int32(MemoryLayout.size(ofValue: flags))) == 0 && (flags & Int(CS_DEBUGGED)) != 0 ? allocateTest() : false
}
func checkMemoryPermissions(at address: UnsafeRawPointer) -> Bool {
var region: vm_address_t = vm_address_t(UInt(bitPattern: address))
var regionSize: vm_size_t = 0
@ -27,8 +40,7 @@ func checkMemoryPermissions(at address: UnsafeRawPointer) -> Bool {
return info.protection & VM_PROT_EXECUTE != 0
}
func isJITEnabled() -> Bool {
func allocateTest() -> Bool {
let pageSize = sysconf(_SC_PAGESIZE)
let code: [UInt32] = [0x52800540, 0xD65F03C0]
@ -40,7 +52,6 @@ func isJITEnabled() -> Bool {
munmap(jitMemory, pageSize)
}
memcpy(jitMemory, code, code.count)
if mprotect(jitMemory, pageSize, PROT_READ | PROT_EXEC) != 0 {

View File

@ -11,6 +11,7 @@ func enableJITEB() {
guard let bundleID = Bundle.main.bundleIdentifier else {
return
}
let address = URL(string: "http://[fd00::]:9172/launch_app/\(bundleID)")!
let task = URLSession.shared.dataTask(with: address) { data, response, error in

View File

@ -91,10 +91,10 @@ class NativeController: Hashable {
guard let gamepad = nativeController.extendedGamepad
else { return }
setupButtonChangeListener(gamepad.buttonA, for: .B)
setupButtonChangeListener(gamepad.buttonB, for: .A)
setupButtonChangeListener(gamepad.buttonX, for: .Y)
setupButtonChangeListener(gamepad.buttonY, for: .X)
setupButtonChangeListener(gamepad.buttonA, for: .A)
setupButtonChangeListener(gamepad.buttonB, for: .B)
setupButtonChangeListener(gamepad.buttonX, for: .X)
setupButtonChangeListener(gamepad.buttonY, for: .Y)
setupButtonChangeListener(gamepad.dpad.up, for: .dPadUp)
setupButtonChangeListener(gamepad.dpad.down, for: .dPadDown)

View File

@ -45,7 +45,9 @@ class VirtualController {
},
Rumble: { userdata, lowFreq, highFreq in
print("Rumble with \(lowFreq), \(highFreq)")
VirtualController.rumble(lowFreq: Float(lowFreq), highFreq: Float(highFreq))
if UIDevice.current.userInterfaceIdiom == .phone {
VirtualController.rumble(lowFreq: Float(lowFreq), highFreq: Float(highFreq))
}
return 0
},
RumbleTriggers: { userdata, leftRumble, rightRumble in
@ -80,7 +82,6 @@ class VirtualController {
static func rumble(lowFreq: Float, highFreq: Float, engine: CHHapticEngine? = nil) {
do {
// Low-frequency haptic pattern
let lowFreqPattern = try CHHapticPattern(events: [
CHHapticEvent(eventType: .hapticTransient, parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: lowFreq),
@ -88,7 +89,7 @@ class VirtualController {
], relativeTime: 0, duration: 0.2)
], parameters: [])
// High-frequency haptic pattern
let highFreqPattern = try CHHapticPattern(events: [
CHHapticEvent(eventType: .hapticTransient, parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: highFreq),
@ -96,12 +97,9 @@ class VirtualController {
], relativeTime: 0.2, duration: 0.2)
], parameters: [])
// Mutable engine
var engine = engine
// If no engine passed, use device engine
if engine == nil {
// Create and start the haptic engine
if hapticEngine == nil {
hapticEngine = try CHHapticEngine()
try hapticEngine?.start()
@ -114,13 +112,11 @@ class VirtualController {
return print("Error creating haptic patterns: hapticEngine is nil")
}
// Create and play the low-frequency player
let lowFreqPlayer = try engine.makePlayer(with: lowFreqPattern)
try lowFreqPlayer.start(atTime: 0)
// Create and play the high-frequency player after a short delay
let highFreqPlayer = try engine.makePlayer(with: highFreqPattern)
try highFreqPlayer.start(atTime: 0.2)
try highFreqPlayer.start(atTime: 0)
} catch {
print("Error creating haptic patterns: \(error)")

View File

@ -1,104 +0,0 @@
//
// Untitled.swift
// MeloNX
//
// Created by Stossy11 on 28/11/2024.
//
import Foundation
import GameController
import UIKit
import SwiftUI
var theWindow: UIWindow? = nil
extension UIWindow {
// Makes the SDLWindow use the current WindowScene instead of making its own window.
// Also waits for the window to append the on-screen controller
@objc func wdb_makeKeyAndVisible() {
let enabled = UserDefaults.standard.bool(forKey: "oldWindowCode")
if #unavailable(iOS 17.0), enabled {
self.windowScene = (UIApplication.shared.connectedScenes.first! as! UIWindowScene)
}
self.wdb_makeKeyAndVisible()
theWindow = self
if #available(iOS 17, *) {
Ryujinx.shared.repeatuntilfindLayer()
} else if UserDefaults.standard.bool(forKey: "isVirtualController") && enabled {
waitForController()
}
}
}
// MARK: - iOS 16 and below Only
var hostingController: UIHostingController<ControllerView>?
func waitForController() {
guard let window = theWindow else { return }
// Function to search for an existing UIHostingController with ControllerView
func findGCControllerView(in view: UIView) -> UIHostingController<ControllerView>? {
if let hostingVC = view.next as? UIHostingController<ControllerView> {
return hostingVC
}
for subview in view.subviews {
if let found = findGCControllerView(in: subview) {
return found
}
}
return nil
}
let controllerView = ControllerView()
let newHostingController = UIHostingController(rootView: controllerView)
hostingController = newHostingController
let containerView = newHostingController.view!
containerView.backgroundColor = .clear
containerView.frame = window.bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Timer for controller
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
if findGCControllerView(in: window) == nil {
// Adds Virtual Controller Subview
window.addSubview(containerView)
window.bringSubviewToFront(containerView)
if let sdlWindow = SDL_GetWindowFromID(1) {
SDL_SetWindowPosition(sdlWindow, 0, 0)
}
timer.invalidate()
}
}
}
class TransparentHostingContainerView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Check if the point is within the subviews of this container
let view = super.hitTest(point, with: event)
print(view)
// Return nil if the touch is outside visible content (passes through to views below)
return view === self ? nil : view
}
}
// Patches makeKeyAndVisible to wdb_makeKeyAndVisible
func patchMakeKeyAndVisible() {
let uiwindowClass = UIWindow.self
if let m1 = class_getInstanceMethod(uiwindowClass, #selector(UIWindow.makeKeyAndVisible)),
let m2 = class_getInstanceMethod(uiwindowClass, #selector(UIWindow.wdb_makeKeyAndVisible)) {
method_exchangeImplementations(m1, m2)
}
}

View File

@ -8,6 +8,8 @@
import Foundation
import SwiftUI
import GameController
import MetalKit
import Metal
struct Controller: Identifiable, Hashable {
var id: String
@ -37,7 +39,8 @@ class Ryujinx {
@Published var controllerMap: [Controller] = []
@Published var metalLayer: CAMetalLayer? = nil
@Published var firmwareversion = "0"
@Published var emulationUIView = UIView()
@Published var emulationUIView: MeloMTKView? = nil
@Published var config: Ryujinx.Configuration? = nil
@Published var games: [Game] = []
@Published var defMLContentSize: CGFloat?
@ -140,9 +143,11 @@ class Ryujinx {
throw RyujinxError.alreadyRunning
}
isRunning = true
self.config = config
RunLoop.current.perform {
RunLoop.current.perform { [self] in
isRunning = true
let url = URL(string: config.gamepath)
@ -156,15 +161,17 @@ class Ryujinx {
var argvPtrs = cArgs
// Start the emulation
let result = main_ryujinx_sdl(Int32(args.count), &argvPtrs)
if result != 0 {
self.isRunning = false
if let accessing, accessing {
url!.stopAccessingSecurityScopedResource()
}
if isRunning {
let result = main_ryujinx_sdl(Int32(args.count), &argvPtrs)
throw RyujinxError.executionError(code: result)
if result != 0 {
self.isRunning = false
if let accessing, accessing {
url!.stopAccessingSecurityScopedResource()
}
throw RyujinxError.executionError(code: result)
}
}
} catch {
self.isRunning = false
@ -180,6 +187,11 @@ class Ryujinx {
}
isRunning = false
self.emulationUIView = nil
self.metalLayer = nil
stop_emulation()
}
var running: Bool {
@ -334,13 +346,19 @@ class Ryujinx {
}
}
}
// Apped any additional arguments
args.append(contentsOf: config.additionalArgs)
return args
}
func checkIfKeysImported() -> Bool {
let keysDirectory = URL.documentsDirectory.appendingPathComponent("system")
let keysFile = keysDirectory.appendingPathComponent("prod.keys")
return FileManager.default.fileExists(atPath: keysFile.path)
}
func fetchFirmwareVersion() -> String {
do {
let firmwareVersionPointer = installed_firmware_version()
@ -489,15 +507,11 @@ class Ryujinx {
let layer = self.getMetalLayer(nil)
if layer != nil {
DispatchQueue.main.async {
self.metalLayer = layer
}
self.metalLayer = layer
break
}
Thread.sleep(forTimeInterval: 0.1)
try await Task.sleep(nanoseconds: 100_000_000)
}
}
}

View File

@ -1,39 +0,0 @@
//
// MetalView.swift
// MeloNX
//
// Created by Stossy11 on 09/02/2025.
//
import SwiftUI
import MetalKit
struct MetalView: UIViewRepresentable {
var airplay: Bool = Air.shared.connected // just in case :3
func makeUIView(context: Context) -> UIView {
let metalLayer = Ryujinx.shared.metalLayer!
var view = UIView()
metalLayer.frame = view.bounds
if airplay {
metalLayer.contentsScale = view.contentScaleFactor
} else {
Ryujinx.shared.emulationUIView.contentScaleFactor = metalLayer.contentsScale // Right size and Fix Touch :3
}
Ryujinx.shared.emulationUIView = view
if !Ryujinx.shared.emulationUIView.subviews.contains(where: { $0 == metalLayer }) {
Ryujinx.shared.emulationUIView.layer.addSublayer(metalLayer)
}
return Ryujinx.shared.emulationUIView
}
func updateUIView(_ uiView: UIView, context: Context) {
// nothin
}
}

View File

@ -1,100 +0,0 @@
//
// LogEntry.swift
// MeloNX
//
// Created by Stossy11 on 09/02/2025.
//
import SwiftUI
struct LogEntry: Identifiable, Equatable {
let id = UUID()
let text: String
static func == (lhs: LogEntry, rhs: LogEntry) -> Bool {
return lhs.id == rhs.id && lhs.text == rhs.text
}
}
struct LogViewer: View {
@State private var logs: [LogEntry] = []
@State private var latestLogFilePath: String?
var body: some View {
VStack {
Spacer()
VStack {
ForEach(logs) { log in
Text(log.text)
.padding(4)
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeOut(duration: 2), value: logs)
}
}
.frame(maxWidth: .infinity)
.padding()
}
.edgesIgnoringSafeArea(.all)
.onAppear {
findNewestLogFile()
}
}
func findNewestLogFile() {
let logsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("logs")
guard let directory = logsDirectory else { return }
do {
let logFiles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles)
// Sort files by modification date (newest first)
let sortedFiles = logFiles.sorted {
(try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast >
(try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast
}
if let newestLogFile = sortedFiles.first {
latestLogFilePath = newestLogFile.path
startReadingLogFile()
}
} catch {
print("Error reading log files: \(error)")
}
}
func startReadingLogFile() {
guard let path = latestLogFilePath else { return }
let fileHandle = try? FileHandle(forReadingAtPath: path)
fileHandle?.seekToEndOfFile()
NotificationCenter.default.addObserver(forName: .NSFileHandleDataAvailable, object: fileHandle, queue: .main) { _ in
if let data = fileHandle?.availableData, !data.isEmpty {
if let logLine = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
DispatchQueue.main.async {
withAnimation {
logs.append(LogEntry(text: logLine))
}
// Remove old logs after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
removelogfirst()
}
}
}
}
}
fileHandle?.waitForDataInBackgroundAndNotify()
}
fileHandle?.waitForDataInBackgroundAndNotify()
}
func removelogfirst() {
logs.removeFirst()
}
}

View File

@ -44,26 +44,37 @@ struct ContentView: View {
@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("ignoreJIT") var ignoreJIT: Bool = false
// Loading Animation
@AppStorage("showlogsloading") var showlogsloading: Bool = true
@State private var clumpOffset: CGFloat = -100
private let clumpWidth: CGFloat = 100
private let animationDuration: Double = 1.0
@State private var isAnimating = false
@State var isLoading = true
@State var jitNotEnabled = false
// MARK: - Initialization
init() {
let defaultConfig = loadSettings() ?? Ryujinx.Configuration(gamepath: "")
_config = State(initialValue: defaultConfig)
var defaultConfig = loadSettings()
if defaultConfig == nil {
saveSettings(config: .init(gamepath: ""))
defaultConfig = loadSettings()
}
_config = State(initialValue: defaultConfig!)
let defaultSettings: [MoltenVKSettings] = [ // Default MoltenVK Settings.
MoltenVKSettings(string: "MVK_USE_METAL_PRIVATE_API", value: "1"),
MoltenVKSettings(string: "MVK_CONFIG_USE_METAL_PRIVATE_API", value: "1"),
MoltenVKSettings(string: "MVK_DEBUG", value: "0"),
MoltenVKSettings(string: "MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS", value: "0"),
MoltenVKSettings(string: "MVK_CONFIG_PREFILL_METAL_COMMAND_BUFFERS", value: "0"),
// Uses more ram but makes performance higher, may add an option in settings to change or enable / disable this value (default 64)
MoltenVKSettings(string: "MVK_CONFIG_MAX_ACTIVE_METAL_COMMAND_BUFFERS_PER_QUEUE", value: "128"),
MoltenVKSettings(string: "MVK_CONFIG_MAX_ACTIVE_METAL_COMMAND_BUFFERS_PER_QUEUE", value: "512"),
]
_settings = State(initialValue: defaultSettings)
@ -73,38 +84,64 @@ struct ContentView: View {
// MARK: - Body
var body: some View {
if game != nil, quits == false {
if isLoading {
if Air.shared.connected {
Text("")
.onAppear() {
Air.play(AnyView(emulationView))
}
} else {
ZStack {
emulationView
}
}
} else {
// This is when the game starts to stop the animation
if game != nil, !jitNotEnabled {
// This is when the game starts to stop the animation
ZStack {
if #available(iOS 16, *) {
EmulationView()
EmulationView(startgame: $game)
.persistentSystemOverlays(.hidden)
.onAppear() {
isAnimating = false
}
} else {
EmulationView()
.onAppear() {
isAnimating = false
}
EmulationView(startgame: $game)
}
if isLoading {
ZStack {
Color.black
.opacity(0.8)
emulationView
.ignoresSafeArea(.all)
}
.edgesIgnoringSafeArea(.all)
.ignoresSafeArea(.all)
}
}
} else if game != nil, ignoreJIT {
ZStack {
if #available(iOS 16, *) {
EmulationView(startgame: $game)
.persistentSystemOverlays(.hidden)
} else {
EmulationView(startgame: $game)
}
if isLoading {
ZStack {
Color.black
.opacity(0.8)
emulationView
.ignoresSafeArea(.all)
}
.edgesIgnoringSafeArea(.all)
.ignoresSafeArea(.all)
}
}
} else if game != nil {
Text("")
.sheet(isPresented: $jitNotEnabled) {
JITPopover() {
jitNotEnabled = false
}
.interactiveDismissDisabled()
}
} else {
// This is the main menu view that includes the Settings and the Game Selector
mainMenuView
.onAppear() {
quits = false
loadSettings()
isLoading = true
initControllerObservers() // This initializes the Controller Observers that refreshes the controller list when a new controller connecvts.
}
@ -204,12 +241,14 @@ struct ContentView: View {
if get_current_fps() != 0 {
withAnimation {
isLoading = false
isAnimating = false
}
isAnimating = false
timer.invalidate()
}
print(get_current_fps())
}
}
}
@ -224,6 +263,11 @@ struct ContentView: View {
y: screenGeometry.size.height * 0.5
)
}
if showlogsloading {
LogFileView(isfps: true)
.frame(alignment: .topLeading)
}
}
}
@ -247,9 +291,9 @@ struct ContentView: View {
}
))
let isJIT = isJITEnabled()
if !isJIT {
useTrollStore ? askForJIT() : enableJITEB()
jitNotEnabled = !isJITEnabled()
if jitNotEnabled {
useTrollStore ? askForJIT() : jitStreamerEB ? enableJITEB() : print("no JIT")
}
}
}
@ -265,7 +309,6 @@ struct ContentView: View {
}
private func setupEmulation() {
patchMakeKeyAndVisible()
isVCA = (currentControllers.first(where: { $0 == onscreencontroller }) != nil)
DispatchQueue.main.async {
@ -319,6 +362,7 @@ struct ContentView: View {
config.inputids.append("0")
}
do {
try Ryujinx.shared.start(with: config)
} catch {
@ -336,21 +380,6 @@ struct ContentView: View {
}
}
// MARK: - Helper Functions
func loadSettings() -> Ryujinx.Configuration? {
guard let jsonString = UserDefaults.standard.string(forKey: "config"),
let data = jsonString.data(using: .utf8) else {
return nil
}
do {
return try JSONDecoder().decode(Ryujinx.Configuration.self, from: data)
} catch {
print("Failed to load settings: \(error)")
return nil
}
}
extension Array {
@inlinable public mutating func mutableForEach(_ body: (inout Element) throws -> Void) rethrows {
for index in self.indices {

View File

@ -11,29 +11,10 @@ import SwiftUIJoystick
import CoreMotion
struct ControllerView: View {
@AppStorage("performacehud") var performacehud: Bool = false
@AppStorage("quit") var quit: Bool = false
var body: some View {
GeometryReader { geometry in
if geometry.size.height > geometry.size.width && UIDevice.current.userInterfaceIdiom != .pad {
VStack {
if performacehud {
HStack {
PerformanceOverlayView()
Spacer()
// Button("Stop emulation") {
// DispatchQueue.main.async {
// stop_emulation()
// quit = true
// }
// }
}
}
Spacer()
VStack {
@ -67,21 +48,6 @@ struct ControllerView: View {
} else {
// could be landscape
VStack {
if performacehud {
HStack {
PerformanceOverlayView()
Spacer()
// Button("Stop emulation") {
// DispatchQueue.main.async {
// stop_emulation()
// quit = true
// }
// }
}
}
Spacer()
VStack {

View File

@ -9,15 +9,22 @@ import SwiftUI
// Emulation View
struct EmulationView: View {
@AppStorage("performacehud") var performacehud: Bool = false
@AppStorage("isVirtualController") var isVCA: Bool = true
@AppStorage("showScreenShotButton") var ssb: Bool = false
@AppStorage("showlogsgame") var showlogsgame: Bool = false
@State var isPresentedThree: Bool = false
@State var isAirplaying = Air.shared.connected
@Binding var startgame: Game?
@Environment(\.scenePhase) var scenePhase
var body: some View {
ZStack {
if isAirplaying {
Text("")
TouchView()
.ignoresSafeArea()
.edgesIgnoringSafeArea(.all)
.onAppear {
Air.play(AnyView(MetalView().ignoresSafeArea()))
}
@ -33,16 +40,35 @@ struct EmulationView: View {
ControllerView() // Virtual Controller
}
if ssb {
Group {
VStack {
Group {
VStack {
HStack {
if performacehud, !showlogsgame {
PerformanceOverlayView()
}
Spacer()
if performacehud, showlogsgame {
PerformanceOverlayView()
}
}
HStack {
if showlogsgame, get_current_fps() != 0 {
LogFileView(isfps: false)
}
Spacer()
}
Spacer()
if ssb {
HStack {
Button {
if let screenshot = Ryujinx.shared.emulationUIView.screenshot() {
if let screenshot = Ryujinx.shared.emulationUIView?.screenshot() {
UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil)
}
} label: {
@ -52,9 +78,12 @@ struct EmulationView: View {
.padding()
Spacer()
}
}
}
}
}

View File

@ -0,0 +1,154 @@
//
// MeloMTKView.swift
// MeloNX
//
// Created by Stossy11 on 03/03/2025.
//
import MetalKit
import UIKit
class MeloMTKView: MTKView {
private var activeTouches: [UITouch] = []
private var ignoredTouches: Set<UITouch> = []
private let baseWidth: CGFloat = 1280
private let baseHeight: CGFloat = 720
private var aspectRatio: AspectRatio = .fixed16x9
func setAspectRatio(_ ratio: AspectRatio) {
self.aspectRatio = ratio
}
private func scaleToTargetResolution(_ location: CGPoint) -> CGPoint? {
let viewWidth = self.frame.width
let viewHeight = self.frame.height
var scaleX: CGFloat
var scaleY: CGFloat
var offsetX: CGFloat = 0
var offsetY: CGFloat = 0
var targetAspect: CGFloat
switch aspectRatio {
case .fixed4x3:
targetAspect = 4.0 / 3.0
case .fixed16x9:
targetAspect = 16.0 / 9.0
case .fixed16x10:
targetAspect = 16.0 / 10.0
case .fixed21x9:
targetAspect = 21.0 / 9.0
case .fixed32x9:
targetAspect = 32.0 / 9.0
case .stretched:
scaleX = baseWidth / viewWidth
scaleY = baseHeight / viewHeight
let adjustedX = location.x
let adjustedY = location.y
let scaledX = max(0, min(adjustedX * scaleX, baseWidth))
let scaledY = max(0, min(adjustedY * scaleY, baseHeight))
return CGPoint(x: scaledX, y: scaledY)
}
let viewAspect = viewWidth / viewHeight
if viewAspect > targetAspect {
let scaledWidth = viewHeight * targetAspect
offsetX = (viewWidth - scaledWidth) / 2
scaleX = baseWidth / scaledWidth
scaleY = baseHeight / viewHeight
} else {
let scaledHeight = viewWidth / targetAspect
offsetY = (viewHeight - scaledHeight) / 2
scaleX = baseWidth / viewWidth
scaleY = baseHeight / scaledHeight
}
if location.x < offsetX || location.x > (viewWidth - offsetX) ||
location.y < offsetY || location.y > (viewHeight - offsetY) {
return nil
}
let adjustedX = location.x - offsetX
let adjustedY = location.y - offsetY
let scaledX = max(0, min(adjustedX * scaleX, baseWidth))
let scaledY = max(0, min(adjustedY * scaleY, baseHeight))
return CGPoint(x: scaledX, y: scaledY)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9)
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))
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
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)
print("Touch ended for index \(index)")
touch_ended(Int32(index))
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
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))
}
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

@ -0,0 +1,49 @@
//
// MetalView.swift
// MeloNX
//
// Created by Stossy11 on 09/02/2025.
//
import SwiftUI
import MetalKit
struct MetalView: UIViewRepresentable {
var airplay: Bool = Air.shared.connected // just in case :3
func makeUIView(context: Context) -> UIView {
if Ryujinx.shared.emulationUIView == nil {
var view = MeloMTKView()
guard let metalLayer = view.layer as? CAMetalLayer else {
fatalError("[Swift] Error: MTKView's layer is not a CAMetalLayer")
}
metalLayer.device = MTLCreateSystemDefaultDevice()
let layerPtr = Unmanaged.passUnretained(metalLayer).toOpaque()
set_native_window(layerPtr)
Ryujinx.shared.emulationUIView = view
Ryujinx.shared.metalLayer = metalLayer
return view
}
let uiview = UIView()
uiview.layer.addSublayer(Ryujinx.shared.metalLayer!)
uiview.contentScaleFactor = Ryujinx.shared.metalLayer!.contentsScale
return uiview
}
func updateUIView(_ uiView: UIView, context: Context) {
// nothin
}
}

View File

@ -0,0 +1,18 @@
//
// TouchView.swift
// MeloNX
//
// Created by Stossy11 on 05/03/2025.
//
import SwiftUI
import MetalKit
struct TouchView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
var view = MeloMTKView()
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

View File

@ -134,7 +134,9 @@ struct GameLibraryView: View {
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
isSelectingGameFile.toggle()
isSelectingGameFile = true
isImporting = true
} label: {
Image(systemName: "plus")
}
@ -171,16 +173,17 @@ struct GameLibraryView: View {
} label: {
Text("Mii Maker")
}
Button {
DispatchQueue.main.async {
isImporting.toggle()
}
} label: {
Text("Open game from system")
}
}
}
Button {
isSelectingGameFile = false
isImporting = true
} label: {
Text("Open Game")
}
Button {
let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
var sharedurl = documentsUrl.absoluteString.replacingOccurrences(of: "file://", with: "shareddocuments://")
@ -211,62 +214,64 @@ struct GameLibraryView: View {
.onChange(of: searchText) { _ in
isSearching = !searchText.isEmpty
}
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.zip, .folder, .nsp, .xci]) { result in
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
print("Failed to access security-scoped resource")
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let handle = try FileHandle(forReadingFrom: url)
let fileExtension = (url.pathExtension as NSString).utf8String
let extensionPtr = UnsafeMutablePointer<CChar>(mutating: fileExtension)
var gameInfo = get_game_info(handle.fileDescriptor, extensionPtr)
let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url)
DispatchQueue.main.async {
startemu = game
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.folder, .nsp, .xci, .zip, .item]) { result in
if isSelectingGameFile {
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
print("Failed to access security-scoped resource")
return
}
} catch {
print(error)
}
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
}
}
.fileImporter(isPresented: $isSelectingGameFile, allowedContentTypes: [.nsp, .xci, .zip, .folder]) { result in
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")
defer { url.stopAccessingSecurityScopedResource() }
if !fileManager.fileExists(atPath: romsDirectory.path) {
try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil)
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)")
}
} else {
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
print("Failed to access security-scoped resource")
return
}
let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent)
try fileManager.copyItem(at: url, to: destinationURL)
do {
let handle = try FileHandle(forReadingFrom: url)
let fileExtension = (url.pathExtension as NSString).utf8String
let extensionPtr = UnsafeMutablePointer<CChar>(mutating: fileExtension)
var gameInfo = get_game_info(handle.fileDescriptor, extensionPtr)
let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url)
DispatchQueue.main.async {
startemu = game
}
} catch {
print(error)
}
Ryujinx.shared.games = Ryujinx.shared.loadGames()
} catch {
print("Error copying game file: \(error)")
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
}
case .failure(let err):
print("File import failed: \(err.localizedDescription)")
}
}
.sheet(isPresented: $isSelectingGameUpdate) {

View File

@ -0,0 +1,44 @@
//
// JITPopover.swift
// MeloNX
//
// Created by Stossy11 on 05/03/2025.
//
import SwiftUI
struct JITPopover: View {
var onJITEnabled: () -> Void
@Environment(\.dismiss) var dismiss
@State var isJIT: Bool = false
var body: some View {
VStack(spacing: 10) {
Image(systemName: "cpu.fill")
.font(.largeTitle)
.foregroundColor(.blue)
Text("Waiting for JIT")
.font(.headline)
.foregroundColor(.primary)
Text("JIT (Just-In-Time) compilation allows MeloNX to run code at as fast as possible by translating it dynamically. This is necessary for running this emulator.")
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
}
.padding()
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
isJIT = isJITEnabled()
if isJIT {
dismiss()
onJITEnabled()
}
}
}
}
}

View File

@ -0,0 +1,117 @@
//
// LogEntry.swift
// MeloNX
//
// Created by Stossy11 on 09/02/2025.
//
import SwiftUI
struct LogFileView: View {
@State private var logs: [String] = []
@State private var showingLogs = false
public var isfps: Bool
private let fileManager = FileManager.default
private let maxDisplayLines = 10
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
return formatter
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(logs.suffix(maxDisplayLines), id: \.self) { log in
Text(log)
.font(.caption)
.foregroundColor(.white)
.padding(4)
.background(Color.black.opacity(0.7))
.cornerRadius(4)
.transition(.opacity)
}
}
.onAppear {
startLogFileWatching()
}
.onChange(of: logs) { newLogs in
print("Logs updated: \(newLogs.count) entries")
}
}
private func getLatestLogFile() -> URL? {
let logsDirectory = URL.documentsDirectory.appendingPathComponent("Logs")
let currentDate = Date()
do {
try fileManager.createDirectory(at: logsDirectory, withIntermediateDirectories: true)
let logFiles = try fileManager.contentsOfDirectory(at: logsDirectory, includingPropertiesForKeys: [.creationDateKey])
.filter {
let filename = $0.lastPathComponent
guard filename.hasPrefix("Ryujinx_ios_") && filename.hasSuffix(".log") else {
return false
}
let dateString = filename.replacingOccurrences(of: "Ryujinx_ios_", with: "").replacingOccurrences(of: ".log", with: "")
guard let logDate = dateFormatter.date(from: dateString) else {
return false
}
return Calendar.current.isDate(logDate, inSameDayAs: currentDate)
}
let sortedLogFiles = logFiles.sorted {
$0.lastPathComponent > $1.lastPathComponent
}
return sortedLogFiles.first
} catch {
print("Error finding log files: \(error)")
return nil
}
}
private func readLatestLogFile() {
guard let logFileURL = getLatestLogFile() else {
print("no logs?")
return
}
print(logFileURL)
do {
let logContents = try String(contentsOf: logFileURL)
let allLines = logContents.components(separatedBy: .newlines)
DispatchQueue.global(qos: .userInteractive).async {
self.logs = Array(allLines)
}
} catch {
print("Error reading log file: \(error)")
}
}
private func startLogFileWatching() {
showingLogs = true
self.readLatestLogFile()
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
if showingLogs {
self.readLatestLogFile()
}
if isfps {
if get_current_fps() != 0 {
stopLogFileWatching()
timer.invalidate()
}
}
}
}
private func stopLogFileWatching() {
showingLogs = false
}
}

View File

@ -42,6 +42,12 @@ struct SettingsView: View {
@AppStorage("On-ScreenControllerScale") var controllerScale: Double = 1.0
@AppStorage("hasbeenfinished") var finishedStorage: Bool = false
@AppStorage("showlogsloading") var showlogsloading: Bool = true
@AppStorage("showlogsgame") var showlogsgame: Bool = false
@State private var showResolutionInfo = false
@State private var showAnisotropicInfo = false
@State private var showControllerInfo = false
@ -275,7 +281,7 @@ struct SettingsView: View {
// Input Settings
Section {
Toggle(isOn: $config.macroHLE) {
Toggle(isOn: $config.handHeldController) {
labelWithIcon("Player 1 to Handheld Input", iconName: "formfitting.gamecontroller")
}.tint(.blue)
@ -382,7 +388,7 @@ struct SettingsView: View {
labelWithIcon("Disable PTC", iconName: "cpu")
}.tint(.blue)
if let cpuInfo = getCPUInfo(), cpuInfo.hasPrefix("Apple M") {
if let gpuInfo = getGPUInfo(), gpuInfo.hasPrefix("Apple M") {
if #available (iOS 16.4, *) {
Toggle(isOn: .constant(false)) {
labelWithIcon("Hypervisor", iconName: "bolt")
@ -390,7 +396,7 @@ struct SettingsView: View {
.tint(.blue)
.disabled(true)
.onAppear() {
print("CPU Info: \(cpuInfo)")
print("CPU Info: \(gpuInfo)")
}
} else if checkAppEntitlement("com.apple.private.hypervisor") {
Toggle(isOn: $config.hypervisor) {
@ -398,7 +404,7 @@ struct SettingsView: View {
}
.tint(.blue)
.onAppear() {
print("CPU Info: \(cpuInfo)")
print("CPU Info: \(gpuInfo)")
}
}
}
@ -489,6 +495,14 @@ struct SettingsView: View {
}
DisclosureGroup {
Toggle(isOn: $showlogsloading) {
labelWithIcon("Show logs while loading", iconName: "text.alignleft")
}.tint(.blue)
Toggle(isOn: $showlogsgame) {
labelWithIcon("Show logs in-game", iconName: "text.line.magnify")
}.tint(.blue)
Toggle(isOn: $config.debuglogs) {
labelWithIcon("Debug Logs", iconName: "exclamationmark.bubble")
}
@ -529,7 +543,11 @@ struct SettingsView: View {
labelWithIcon("Device: \(getDeviceModel())", iconName: iconName)
labelWithIcon("Device Memory: \(String(format: "%.0f GB", Double(totalMemory) / 1_000_000_000))", iconName: "memorychip.fill")
if ProcessInfo.processInfo.isiOSAppOnMac {
labelWithIcon("Device Memory: \(String(format: "%.0f GB", Double(totalMemory) / (1024 * 1024 * 1024)))", iconName: "memorychip.fill")
} else {
labelWithIcon("Device Memory: \(String(format: "%.0f GB", (Double(totalMemory) / (1024 * 1024 * 1024) + 1)))", iconName: "memorychip.fill")
}
labelWithIcon("\(deviceType) \(UIDevice.current.systemVersion)", iconName: "applelogo")
@ -544,11 +562,6 @@ struct SettingsView: View {
// Advanced
Section {
Toggle(isOn: $windowCode) {
labelWithIcon("SDL Window", iconName: "macwindow.on.rectangle")
}
.tint(.blue)
DisclosureGroup {
Toggle(isOn: $mVKPreFillBuffer) {
@ -567,6 +580,10 @@ struct SettingsView: View {
}
Toggle(isOn: $ignoreJIT) {
labelWithIcon("Ignore JIT Popup", iconName: "cpu")
}.tint(.blue)
TextField("Additional Arguments", text: Binding(
get: {
config.additionalArgs.joined(separator: " ")
@ -580,12 +597,13 @@ struct SettingsView: View {
.textInputAutocapitalization(.none)
.disableAutocorrection(true)
Button {
Ryujinx.shared.removeFirmware()
finishedStorage = false
} label: {
Text("Remove Firmware")
Text("Show Setup")
.font(.body)
}
@ -599,11 +617,7 @@ struct SettingsView: View {
.textCase(nil)
.headerProminence(.increased)
} footer: {
if #available(iOS 17, *) {
Text("For advanced users. See page size or add custom arguments for experimental features. (Please don't touch this if you don't know what you're doing). \n \n\(gamepo ? "the cake is a lie" : "")")
} else {
Text("For advanced users. See page size or add custom arguments for experimental features. (Please don't touch this if you don't know what you're doing). If the emulation is not showing (you may hear audio in some games), try enabling \"SDL Window\" \n \n\(gamepo ? "the cake is a lie" : "")")
}
Text("For advanced users. See page size or add custom arguments for experimental features. (Please don't touch this if you don't know what you're doing). \n \n\(gamepo ? "the cake is a lie" : "")")
}
}
@ -614,6 +628,8 @@ struct SettingsView: View {
.onAppear {
if let configs = loadSettings() {
self.config = configs
} else {
saveSettings()
}
}
.onChange(of: config) { _ in
@ -631,6 +647,10 @@ struct SettingsView: View {
}
}
func saveSettings() {
MeloNX.saveSettings(config: config)
}
func getDeviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
@ -641,26 +661,9 @@ struct SettingsView: View {
}
return identifier
}
func saveSettings() {
#if targetEnvironment(simulator)
print("Saving Settings")
#else
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(config)
let jsonString = String(data: data, encoding: .utf8)
UserDefaults.standard.set(jsonString, forKey: "config")
} catch {
print("Failed to save settings: \(error)")
}
#endif
}
func getCPUInfo() -> String? {
func getGPUInfo() -> String? {
let device = MTLCreateSystemDefaultDevice()
let gpu = device?.name
@ -669,29 +672,6 @@ struct SettingsView: View {
}
// Original loadSettings function assumed to exist
func loadSettings() -> Ryujinx.Configuration? {
#if targetEnvironment(simulator)
print("Running on Simulator")
return Ryujinx.Configuration(gamepath: "")
#else
guard let jsonString = UserDefaults.standard.string(forKey: "config"),
let data = jsonString.data(using: .utf8) else {
return nil
}
do {
let decoder = JSONDecoder()
let configs = try decoder.decode(Ryujinx.Configuration.self, from: data)
return configs
} catch {
print("Failed to load settings: \(error)")
return nil
}
#endif
}
@ViewBuilder
private func labelWithIcon(_ text: String, iconName: String, flipimage: Bool? = nil) -> some View {
HStack(spacing: 8) {
@ -724,7 +704,7 @@ struct SVGView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
var svgName = svgName
var hammock = UIView()
let hammock = UIView()
if svgName.hasSuffix(".svg") {
svgName.removeLast(4)
@ -732,7 +712,7 @@ struct SVGView: UIViewRepresentable {
let svgLayer = UIView(SVGNamed: svgName) { svgLayer in
_ = UIView(svgNamed: svgName) { svgLayer in
svgLayer.fillColor = UIColor(color).cgColor // Apply the provided color
svgLayer.resizeToFit(hammock.frame)
hammock.layer.addSublayer(svgLayer)
@ -748,3 +728,38 @@ struct SVGView: UIViewRepresentable {
}
}
}
func saveSettings(config: Ryujinx.Configuration) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(config)
let fileURL = URL.documentsDirectory.appendingPathComponent("config.json")
try data.write(to: fileURL)
print("Settings saved to: \(fileURL.path)")
} catch {
print("Failed to save settings: \(error)")
}
}
func loadSettings() -> Ryujinx.Configuration? {
do {
let fileURL = URL.documentsDirectory.appendingPathComponent("config.json")
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("Config file does not exist at: \(fileURL.path)")
return nil
}
let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
let configs = try decoder.decode(Ryujinx.Configuration.self, from: data)
return configs
} catch {
print("Failed to load settings: \(error)")
return nil
}
}

View File

@ -18,11 +18,26 @@ struct MeloNXApp: App {
@Environment(\.scenePhase) var scenePhase
@State var alert: UIAlertController? = nil
@State var finished = false
@AppStorage("hasbeenfinished") var finishedStorage: Bool = false
var body: some Scene {
WindowGroup {
ZStack {
if showed || DRM != 1 {
ContentView()
if finishedStorage {
ContentView()
} else {
SetupView(finished: $finished)
.onChange(of: finished) { newValue in
withAnimation {
withAnimation {
finishedStorage = newValue
}
}
}
}
} else {
Group {
VStack {

View File

@ -0,0 +1,394 @@
//
// SetupView.swift
// MeloNX
//
// Created by Stossy11 on 04/03/2025.
//
import SwiftUI
import UniformTypeIdentifiers
struct SetupView: View {
@State private var isImportingKeys = false
@State private var isImportingFirmware = false
@State private var showAlert = false
@State private var showSkipAlert = false
@State private var alertMessage = ""
@State private var keysImported = false
@State private var firmImported = false
@Binding var finished: Bool
var body: some View {
iOSNav {
ZStack {
if UIDevice.current.userInterfaceIdiom == .pad {
iPadSetupView(
finished: $finished,
isImportingKeys: $isImportingKeys,
isImportingFirmware: $isImportingFirmware,
keysImported: keysImported,
firmImported: firmImported
)
} else {
iPhoneSetupView(
finished: $finished,
isImportingKeys: $isImportingKeys,
isImportingFirmware: $isImportingFirmware,
keysImported: keysImported,
firmImported: firmImported
)
}
}
.fileImporter(
isPresented: $isImportingKeys,
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
handleKeysImport(result: result)
}
}
.fileImporter(
isPresented: $isImportingFirmware,
allowedContentTypes: [.item],
allowsMultipleSelection: false
) { result in
handleFirmwareImport(result: result)
}
.alert(alertMessage, isPresented: $showAlert) {
Button("OK", role: .cancel) {}
}
.alert("Skip Setup?", isPresented: $showSkipAlert) {
Button("Skip", role: .destructive) { finished = true }
Button("Cancel", role: .cancel) {}
}
.onAppear {
initialize()
finished = false
keysImported = Ryujinx.shared.checkIfKeysImported()
firmImported = (Ryujinx.shared.fetchFirmwareVersion() != "0")
}
}
private func iPadSetupView(
finished: Binding<Bool>,
isImportingKeys: Binding<Bool>,
isImportingFirmware: Binding<Bool>,
keysImported: Bool,
firmImported: Bool
) -> some View {
GeometryReader { geometry in
ZStack {
LinearGradient(
gradient: Gradient(colors: [
Color.blue.opacity(0.1),
Color.red.opacity(0.1)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
HStack(spacing: 40) {
if geometry.size.width > 800 {
VStack(alignment: .center, spacing: 20) {
Image(uiImage: UIImage(named: appIcon()) ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 40))
.overlay(
RoundedRectangle(cornerRadius: 40)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
.blue.opacity(0.6),
.red.opacity(0.6)
]),
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 2
)
)
.shadow(color: .black.opacity(0.1), radius: 15, x: 0, y: 6)
Text("Welcome to MeloNX")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
Text("Set up your Nintendo Switch emulation environment by importing keys and firmware.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
}
.frame(maxWidth: 400)
}
VStack(spacing: 20) {
setupStep(
title: "Import Keys",
description: "Add your encryption keys",
systemImage: "key.fill",
isCompleted: keysImported,
action: { isImportingKeys.wrappedValue = true }
)
setupStep(
title: "Add Firmware",
description: "Install Nintendo Switch firmware",
systemImage: "square.and.arrow.down",
isCompleted: firmImported,
isEnabled: keysImported,
action: { isImportingFirmware.wrappedValue = true }
)
Button(action: { finished.wrappedValue = true }) {
HStack {
Text("Finish Setup")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(
firmImported && keysImported
? Color.blue
: Color.blue.opacity(0.3)
)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(!(firmImported && keysImported))
}
.frame(maxWidth: 500)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
.navigationTitle("Setup")
.navigationBarTitleDisplayMode(.inline)
}
}
private func iPhoneSetupView(
finished: Binding<Bool>,
isImportingKeys: Binding<Bool>,
isImportingFirmware: Binding<Bool>,
keysImported: Bool,
firmImported: Bool
) -> some View {
ZStack {
LinearGradient(
gradient: Gradient(colors: [
Color.blue.opacity(0.1),
Color.red.opacity(0.1)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 20) {
Image(uiImage: UIImage(named: appIcon()) ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
.blue.opacity(0.6),
.red.opacity(0.6)
]),
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 2
)
)
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: 4)
.padding(.top, 40)
Text("Welcome to MeloNX")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.primary)
.padding(.bottom, 20)
.onTapGesture(count: 2) {
showSkipAlert = true
}
setupStep(
title: "Import Keys",
description: "Add your encryption keys",
systemImage: "key.fill",
isCompleted: keysImported,
action: { isImportingKeys.wrappedValue = true }
)
setupStep(
title: "Add Firmware",
description: "Install Nintendo Switch firmware",
systemImage: "square.and.arrow.down",
isCompleted: firmImported,
isEnabled: keysImported,
action: { isImportingFirmware.wrappedValue = true }
)
}
.padding()
}
// Finish Button
VStack {
Button(action: { finished.wrappedValue = true }) {
HStack {
Text("Finish Setup")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(
firmImported && keysImported
? Color.blue
: Color.blue.opacity(0.3)
)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(!(firmImported && keysImported))
.padding()
}
}
.navigationTitle("Setup")
.navigationBarTitleDisplayMode(.inline)
}
}
private func setupStep(
title: String,
description: String,
systemImage: String,
isCompleted: Bool,
isEnabled: Bool = true,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack {
Image(systemName: systemImage)
.foregroundColor(isCompleted ? .green : .blue)
.imageScale(.large)
VStack(alignment: .leading) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
.padding()
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(12)
}
.disabled(!isEnabled || isCompleted)
.opacity(isEnabled ? 1.0 : 0.5)
}
private func handleKeysImport(result: Result<[URL], Error>) {
do {
let selectedFiles = try result.get()
guard selectedFiles.count == 2 else {
alertMessage = "Please select exactly 2 key files"
showAlert = true
return
}
for fileURL in selectedFiles {
guard fileURL.startAccessingSecurityScopedResource() else {
alertMessage = "Permission denied to access file"
showAlert = true
return
}
defer {
fileURL.stopAccessingSecurityScopedResource()
}
let destinationURL = URL.documentsDirectory.appendingPathComponent("system").appendingPathComponent(fileURL.lastPathComponent)
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
}
keysImported = Ryujinx.shared.checkIfKeysImported()
alertMessage = "Keys imported successfully"
showAlert = true
} catch {
alertMessage = "Error importing keys: \(error.localizedDescription)"
showAlert = true
}
}
private func handleFirmwareImport(result: Result<[URL], Error>) {
do {
let selectedFiles = try result.get()
guard let fileURL = selectedFiles.first else {
alertMessage = "No file selected"
showAlert = true
return
}
guard fileURL.startAccessingSecurityScopedResource() else {
alertMessage = "Permission denied to access file"
showAlert = true
return
}
defer {
fileURL.stopAccessingSecurityScopedResource()
}
Ryujinx.shared.installFirmware(firmwarePath: fileURL.path)
firmImported = (Ryujinx.shared.fetchFirmwareVersion() != "0")
alertMessage = "Firmware installed successfully"
showAlert = true
} catch {
alertMessage = "Error importing firmware: \(error.localizedDescription)"
showAlert = true
}
}
func appIcon(in bundle: Bundle = .main) -> String {
guard let icons = bundle.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
let iconFileName = iconFiles.last else {
print("Could not find icons in bundle")
return ""
}
return iconFileName
}
}

View File

@ -22,7 +22,7 @@ namespace Ryujinx.Graphics.Vulkan
private static readonly string[] _desirableExtensions = {
ExtConditionalRendering.ExtensionName,
ExtExtendedDynamicState.ExtensionName,
ExtExtendedDynamicState.ExtensionName, // This is unsupported on iOS 16 and below.
ExtTransformFeedback.ExtensionName,
KhrDrawIndirectCount.ExtensionName,
KhrPushDescriptor.ExtensionName,

View File

@ -0,0 +1,81 @@
using Ryujinx.Common.Logging;
using Ryujinx.HLE.HOS.Ipc;
using Ryujinx.HLE.HOS.Kernel.Threading;
using Ryujinx.Horizon.Common;
namespace Ryujinx.HLE.HOS.Services.BluetoothManager.BtmSystem
{
class IBtmSystemCore : IpcService
{
public KEvent _radioEvent;
public int _radioEventhandle;
public KEvent _gamepadPairingEvent;
public int _gamepadPairingEventHandle;
public IBtmSystemCore() { }
[CommandCmif(6)]
// IsRadioEnabled() -> b8
public ResultCode IsRadioEnabled(ServiceCtx context)
{
context.ResponseData.Write(true);
Logger.Stub?.PrintStub(LogClass.ServiceBtm);
return ResultCode.Success;
}
[CommandCmif(7)] // 3.0.0+
// AcquireRadioEvent() -> (byte<1>, handle<copy>)
public ResultCode AcquireRadioEvent(ServiceCtx context)
{
Result result = Result.Success;
if (_radioEventhandle == 0)
{
_radioEvent = new KEvent(context.Device.System.KernelContext);
result = context.Process.HandleTable.GenerateHandle(_radioEvent.ReadableEvent, out _radioEventhandle);
if (result != Result.Success)
{
// NOTE: We use a Logging instead of an exception because the call return a boolean if succeed or not.
Logger.Error?.Print(LogClass.ServiceBsd, "Out of handles!");
}
}
context.Response.HandleDesc = IpcHandleDesc.MakeCopy(_radioEventhandle);
context.ResponseData.Write(result == Result.Success ? 1 : 0);
return ResultCode.Success;
}
[CommandCmif(8)] // 3.0.0+
// AcquireGamepadPairingEvent() -> (byte<1>, handle<copy>)
public ResultCode AcquireGamepadPairingEvent(ServiceCtx context)
{
Result result = Result.Success;
if (_gamepadPairingEventHandle == 0)
{
_gamepadPairingEvent = new KEvent(context.Device.System.KernelContext);
result = context.Process.HandleTable.GenerateHandle(_gamepadPairingEvent.ReadableEvent, out _gamepadPairingEventHandle);
if (result != Result.Success)
{
// NOTE: We use a Logging instead of an exception because the call return a boolean if succeed or not.
Logger.Error?.Print(LogClass.ServiceBsd, "Out of handles!");
}
}
context.Response.HandleDesc = IpcHandleDesc.MakeCopy(_gamepadPairingEventHandle);
context.ResponseData.Write(result == Result.Success ? 1 : 0);
return ResultCode.Success;
}
}
}

View File

@ -122,7 +122,8 @@ namespace Ryujinx.HLE.HOS.Services
if (serviceExists)
{
Logger.Trace?.Print(LogClass.KernelIpc, $"{service.GetType().Name}: {processRequest.Name}");
result = (ResultCode)processRequest.Invoke(service, new object[] { context });
}
else

View File

@ -0,0 +1,173 @@
using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
using Ryujinx.Input.HLE;
using Ryujinx.SDL2.Common;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using static SDL2.SDL;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.EXT;
using Silk.NET.Vulkan.Extensions.KHR;
namespace Ryujinx.Headless.SDL2.Vulkan
{
class MoltenVKWindow : WindowBase
{
public IntPtr nativeMetalLayer = IntPtr.Zero;
private Vk _vk;
private ExtMetalSurface _metalSurface;
private SurfaceKHR _surface;
private bool _surfaceCreated;
public MoltenVKWindow(
InputManager inputManager,
GraphicsDebugLevel glLogLevel,
AspectRatio aspectRatio,
bool enableMouse,
HideCursorMode hideCursorMode) : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursorMode)
{
_vk = Vk.GetApi();
_surfaceCreated = false;
}
public override SDL_WindowFlags GetWindowFlags() => SDL_WindowFlags.SDL_WINDOW_VULKAN;
protected override void InitializeWindowRenderer() {}
protected override void InitializeRenderer()
{
if (IsExclusiveFullscreen)
{
Renderer?.Window.SetSize(ExclusiveFullscreenWidth, ExclusiveFullscreenHeight);
MouseDriver.SetClientSize(ExclusiveFullscreenWidth, ExclusiveFullscreenHeight);
}
else
{
Renderer?.Window.SetSize(DefaultWidth, DefaultHeight);
MouseDriver.SetClientSize(DefaultWidth, DefaultHeight);
}
}
public void SetNativeWindow(IntPtr metalLayer)
{
if (metalLayer == IntPtr.Zero)
{
return;
}
nativeMetalLayer = IntPtr.Zero;
nativeMetalLayer = metalLayer;
}
private static void BasicInvoke(Action action)
{
action();
}
public unsafe IntPtr CreateWindowSurface(IntPtr instanceHandle)
{
if (_surfaceCreated)
{
return (IntPtr)(ulong)_surface.Handle;
}
if (nativeMetalLayer == IntPtr.Zero)
{
throw new Exception("Cannot create Vulkan surface: No CAMetalLayer set");
}
var instance = new Instance((nint)instanceHandle);
if (!_vk.TryGetInstanceExtension(instance, out _metalSurface))
{
throw new Exception("Failed to get ExtMetalSurface extension");
}
var createInfo = new MetalSurfaceCreateInfoEXT
{
SType = StructureType.MetalSurfaceCreateInfoExt,
PNext = null,
PLayer = (nint*)nativeMetalLayer
};
SurfaceKHR* surfacePtr = stackalloc SurfaceKHR[1];
Result result = _metalSurface.CreateMetalSurface(instance, &createInfo, null, surfacePtr);
if (result != Result.Success)
{
throw new Exception($"vkCreateMetalSurfaceEXT failed with error code {result}");
}
_surface = *surfacePtr;
_surfaceCreated = true;
return (IntPtr)(ulong)_surface.Handle;
}
public unsafe string[] GetRequiredInstanceExtensions()
{
List<string> requiredExtensions = new List<string>
{
"VK_KHR_surface",
"VK_EXT_metal_surface"
};
uint extensionCount = 0;
_vk.EnumerateInstanceExtensionProperties((byte*)null, &extensionCount, null);
if (extensionCount == 0)
{
string errorMessage = "Failed to enumerate Vulkan instance extensions";
Logger.Error?.Print(LogClass.Application, errorMessage);
throw new Exception(errorMessage);
}
ExtensionProperties* extensions = stackalloc ExtensionProperties[(int)extensionCount];
Result result = _vk.EnumerateInstanceExtensionProperties((byte*)null, &extensionCount, extensions);
if (result != Result.Success)
{
string errorMessage = $"Failed to enumerate Vulkan instance extensions, error: {result}";
Logger.Error?.Print(LogClass.Application, errorMessage);
throw new Exception(errorMessage);
}
List<string> availableExtensions = new List<string>();
for (int i = 0; i < extensionCount; i++)
{
string extName = Marshal.PtrToStringAnsi((IntPtr)extensions[i].ExtensionName);
availableExtensions.Add(extName);
}
Logger.Info?.Print(LogClass.Application, $"Available Vulkan extensions: {string.Join(", ", availableExtensions)}");
foreach (string requiredExt in requiredExtensions)
{
if (!availableExtensions.Contains(requiredExt))
{
string errorMessage = $"Required Vulkan extension {requiredExt} is not available";
Logger.Error?.Print(LogClass.Application, errorMessage);
}
}
Logger.Info?.Print(LogClass.Application, $"Using Vulkan extensions: {string.Join(", ", requiredExtensions)}");
return requiredExtensions.ToArray();
}
protected override void FinalizeWindowRenderer()
{
if (_surfaceCreated)
{
_surface = default;
_surfaceCreated = false;
}
nativeMetalLayer = IntPtr.Zero;
}
protected override void SwapBuffers() {}
}
}

View File

@ -113,6 +113,8 @@ namespace Ryujinx.Headless.SDL2
private static List<InputConfig> _inputConfiguration;
private static bool _enableKeyboard;
private static bool _enableMouse;
private static IntPtr nativeMetalLayer = IntPtr.Zero;
private static readonly object metalLayerLock = new object();
private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
private static readonly TitleUpdateMetadataJsonSerializerContext _titleSerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
@ -142,6 +144,19 @@ namespace Ryujinx.Headless.SDL2
return 0;
}
[UnmanagedCallersOnly(EntryPoint = "set_native_window")]
public static unsafe void SetNativeWindow(IntPtr layer) {
lock (metalLayerLock) {
nativeMetalLayer = layer;
Logger.Info?.Print(LogClass.Application, $"SetNativeWindow called with layer: {layer}");
}
}
public static IntPtr GetNativeMetalLayer()
{
return nativeMetalLayer;
}
[UnmanagedCallersOnly(EntryPoint = "get_dlc_nca_list")]
public static unsafe DlcNcaList GetDlcNcaList(IntPtr titleIdPtr, IntPtr pathPtr)
{
@ -391,12 +406,13 @@ namespace Ryujinx.Headless.SDL2
[UnmanagedCallersOnly(EntryPoint = "stop_emulation")]
public static void StopEmulation()
{
if (_window != null)
{
_window.Exit();
_emulationContext.Dispose();
_emulationContext = null;
if (_window._isPaused) {
_window._isPaused = false;
} else {
_window._isPaused = true;
}
}
}
@ -977,8 +993,7 @@ namespace Ryujinx.Headless.SDL2
}
else
{
bool isAppleController = gamepadName.Contains("Apple") ? option.OnScreenCorrespond : false;
bool isNintendoStyle = gamepadName.Contains("Nintendo") || isAppleController;
bool isNintendoStyle = gamepadName.Contains("Nintendo") || gamepadName.Contains("Joycons");
config = new StandardControllerInputConfig
{
@ -1190,7 +1205,7 @@ namespace Ryujinx.Headless.SDL2
}
if (option.InputPath == "MiiMaker") {
string contentPath = _contentManager.GetInstalledContentPath(0x0100000000001009, StorageId.BuiltInSystem, NcaContentType.Program);
string contentPath = _contentManager.GetInstalledContentPath(0x0100000000001000, StorageId.BuiltInSystem, NcaContentType.Program);
option.InputPath = contentPath;
}
@ -1292,18 +1307,25 @@ namespace Ryujinx.Headless.SDL2
private static WindowBase CreateWindow(Options options)
{
return options.GraphicsBackend == GraphicsBackend.Vulkan
? new VulkanWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode)
: new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode);
if (OperatingSystem.IsIOS()) {
return new MoltenVKWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode);
}
else
{
return options.GraphicsBackend == GraphicsBackend.Vulkan
? new VulkanWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode)
: new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursorMode);
}
}
private static IRenderer CreateRenderer(Options options, WindowBase window)
{
if (options.GraphicsBackend == GraphicsBackend.Vulkan && window is VulkanWindow vulkanWindow)
if (options.GraphicsBackend == GraphicsBackend.Vulkan)
{
string preferredGpuId = string.Empty;
Vk api = Vk.GetApi();
// Handle GPU preference selection
if (!string.IsNullOrEmpty(options.PreferredGPUVendor))
{
string preferredGpuVendor = options.PreferredGPUVendor.ToLowerInvariant();
@ -1319,13 +1341,25 @@ namespace Ryujinx.Headless.SDL2
}
}
return new VulkanRenderer(
api,
(instance, vk) => new SurfaceKHR((ulong)(vulkanWindow.CreateWindowSurface(instance.Handle))),
vulkanWindow.GetRequiredInstanceExtensions,
preferredGpuId);
if (window is VulkanWindow vulkanWindow)
{
return new VulkanRenderer(
api,
(instance, vk) => new SurfaceKHR((ulong)(vulkanWindow.CreateWindowSurface(instance.Handle))),
vulkanWindow.GetRequiredInstanceExtensions,
preferredGpuId);
}
else if (window is MoltenVKWindow mvulkanWindow)
{
return new VulkanRenderer(
api,
(instance, vk) => new SurfaceKHR((ulong)(mvulkanWindow.CreateWindowSurface(instance.Handle))),
mvulkanWindow.GetRequiredInstanceExtensions,
preferredGpuId);
}
}
// Fallback to OpenGL renderer if Vulkan is not used
return new OpenGLRenderer();
}
@ -1333,12 +1367,7 @@ namespace Ryujinx.Headless.SDL2
{
BackendThreading threadingMode = options.BackendThreading;
bool threadedGAL = threadingMode == BackendThreading.On || (threadingMode == BackendThreading.Auto && renderer.PreferThreading);
if (threadedGAL)
{
renderer = new ThreadedRenderer(renderer);
}
renderer = new ThreadedRenderer(renderer);
bool AppleHV = false;
@ -1413,6 +1442,11 @@ namespace Ryujinx.Headless.SDL2
Logger.RestartTime();
WindowBase window = CreateWindow(options);
if (window is MoltenVKWindow mvulkanWindow) {
mvulkanWindow.SetNativeWindow(nativeMetalLayer);
}
IRenderer renderer = CreateRenderer(options, window);
_window = window;

View File

@ -66,7 +66,7 @@ namespace Ryujinx.Headless.SDL2
public ScalingFilter ScalingFilter { get; set; }
public int ScalingFilterLevel { get; set; }
public SDL2MouseDriver MouseDriver;
public iOSTouchDriver MouseDriver;
private readonly InputManager _inputManager;
private readonly IKeyboard _keyboardInterface;
private readonly GraphicsDebugLevel _glLogLevel;
@ -81,6 +81,8 @@ namespace Ryujinx.Headless.SDL2
private bool _isStopped;
private uint _windowId;
public bool _isPaused = false;
private string _gpuVendorName;
private readonly AspectRatio _aspectRatio;
@ -93,7 +95,7 @@ namespace Ryujinx.Headless.SDL2
bool enableMouse,
HideCursorMode hideCursorMode)
{
MouseDriver = new SDL2MouseDriver(hideCursorMode);
MouseDriver = new iOSTouchDriver(hideCursorMode);
_inputManager = inputManager;
_inputManager.SetMouseDriver(MouseDriver);
NpadManager = _inputManager.CreateNpadManager();
@ -159,48 +161,59 @@ namespace Ryujinx.Headless.SDL2
private void InitializeWindow()
{
var activeProcess = Device.Processes.ActiveApplication;
var nacp = activeProcess.ApplicationControlProperties;
int desiredLanguage = (int)Device.System.State.DesiredTitleLanguage;
if (this is Ryujinx.Headless.SDL2.Vulkan.MoltenVKWindow) {
string message = $"Not using SDL Windows, Skipping...";
string titleNameSection = string.IsNullOrWhiteSpace(nacp.Title[desiredLanguage].NameString.ToString()) ? string.Empty : $" - {nacp.Title[desiredLanguage].NameString.ToString()}";
string titleVersionSection = string.IsNullOrWhiteSpace(nacp.DisplayVersionString.ToString()) ? string.Empty : $" v{nacp.DisplayVersionString.ToString()}";
string titleIdSection = string.IsNullOrWhiteSpace(activeProcess.ProgramIdText) ? string.Empty : $" ({activeProcess.ProgramIdText.ToUpper()})";
string titleArchSection = activeProcess.Is64Bit ? " (64-bit)" : " (32-bit)";
Logger.Info?.Print(LogClass.Application, message);
Width = DefaultWidth;
Height = DefaultHeight;
Width = DefaultWidth;
Height = DefaultHeight;
if (IsExclusiveFullscreen)
{
Width = ExclusiveFullscreenWidth;
Height = ExclusiveFullscreenHeight;
MouseDriver.SetClientSize(Width, Height);
} else {
var activeProcess = Device.Processes.ActiveApplication;
var nacp = activeProcess.ApplicationControlProperties;
int desiredLanguage = (int)Device.System.State.DesiredTitleLanguage;
DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI;
FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN;
string titleNameSection = string.IsNullOrWhiteSpace(nacp.Title[desiredLanguage].NameString.ToString()) ? string.Empty : $" - {nacp.Title[desiredLanguage].NameString.ToString()}";
string titleVersionSection = string.IsNullOrWhiteSpace(nacp.DisplayVersionString.ToString()) ? string.Empty : $" v{nacp.DisplayVersionString.ToString()}";
string titleIdSection = string.IsNullOrWhiteSpace(activeProcess.ProgramIdText) ? string.Empty : $" ({activeProcess.ProgramIdText.ToUpper()})";
string titleArchSection = activeProcess.Is64Bit ? " (64-bit)" : " (32-bit)";
Width = DefaultWidth;
Height = DefaultHeight;
if (IsExclusiveFullscreen)
{
Width = ExclusiveFullscreenWidth;
Height = ExclusiveFullscreenHeight;
DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI;
FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN;
}
else if (IsFullscreen)
{
DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI;
FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP;
}
// WindowHandle = SDL_GetWindowFromID(1);
WindowHandle = SDL_CreateWindow($"Ryujinx {Program.Version}{titleNameSection}{titleVersionSection}{titleIdSection}{titleArchSection}", 0, 0, Width, Height, DefaultFlags | FullscreenFlag | GetWindowFlags());
if (WindowHandle == IntPtr.Zero)
{
string errorMessage = $"SDL_CreateWindow failed with error \"{SDL_GetError()}\"";
Logger.Error?.Print(LogClass.Application, errorMessage);
throw new Exception(errorMessage);
}
SetWindowIcon();
_windowId = SDL_GetWindowID(WindowHandle);
SDL2Driver.Instance.RegisterWindow(_windowId, HandleWindowEvent);
}
else if (IsFullscreen)
{
DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI;
FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP;
}
// WindowHandle = SDL_GetWindowFromID(1);
WindowHandle = SDL_CreateWindow($"Ryujinx {Program.Version}{titleNameSection}{titleVersionSection}{titleIdSection}{titleArchSection}", 0, 0, Width, Height, DefaultFlags | FullscreenFlag | GetWindowFlags());
if (WindowHandle == IntPtr.Zero)
{
string errorMessage = $"SDL_CreateWindow failed with error \"{SDL_GetError()}\"";
Logger.Error?.Print(LogClass.Application, errorMessage);
throw new Exception(errorMessage);
}
SetWindowIcon();
_windowId = SDL_GetWindowID(WindowHandle);
SDL2Driver.Instance.RegisterWindow(_windowId, HandleWindowEvent);
}
private void HandleWindowEvent(SDL_Event evnt)
@ -232,7 +245,7 @@ namespace Ryujinx.Headless.SDL2
}
else
{
MouseDriver.Update(evnt);
// MouseDriver.Update(evnt);
}
}
@ -289,6 +302,11 @@ namespace Ryujinx.Headless.SDL2
return;
}
if (_isPaused)
{
return;
}
_ticks += _chrono.ElapsedTicks;
_chrono.Restart();
@ -369,6 +387,13 @@ namespace Ryujinx.Headless.SDL2
{
while (_isActive)
{
if (_isPaused)
{
Thread.Sleep(1);
return;
}
UpdateFrame();
SDL_PumpEvents();
@ -408,6 +433,11 @@ namespace Ryujinx.Headless.SDL2
return true;
}
if (_isPaused)
{
return true;
}
if (_isStopped)
{
return false;
@ -416,13 +446,12 @@ namespace Ryujinx.Headless.SDL2
NpadManager.Update();
// Touchscreen
bool hasTouch = false;
bool hasTouch = true;
MouseDriver.SetClientSize(Width, Height);
hasTouch = TouchScreenManager.Update(true, (_inputManager.MouseDriver as iOSTouchDriver).IsButtonPressed(MouseButton.Button1), _aspectRatio.ToFloat());
// Get screen touch position
if (!_enableMouse)
{
hasTouch = TouchScreenManager.Update(true, (_inputManager.MouseDriver as SDL2MouseDriver).IsButtonPressed(MouseButton.Button1), _aspectRatio.ToFloat());
}
if (!hasTouch)
{
@ -545,11 +574,14 @@ namespace Ryujinx.Headless.SDL2
TouchScreenManager?.Dispose();
NpadManager.Dispose();
SDL2Driver.Instance.UnregisterWindow(_windowId);
if (!(this is Ryujinx.Headless.SDL2.Vulkan.MoltenVKWindow))
{
SDL2Driver.Instance.UnregisterWindow(_windowId);
SDL_DestroyWindow(WindowHandle);
SDL_DestroyWindow(WindowHandle);
SDL2Driver.Instance.Dispose();
SDL2Driver.Instance.Dispose();
}
}
}
}

View File

@ -0,0 +1,90 @@
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Input;
using System;
using System.Drawing;
using System.Numerics;
namespace Ryujinx.Headless.SDL2
{
class iOSMouse : IMouse
{
private iOSTouchDriver _driver;
public GamepadFeaturesFlag Features => throw new NotImplementedException();
public string Id => "0";
public string Name => "iOSMouse";
public bool IsConnected => true;
public bool[] Buttons => _driver.PressedButtons;
Size IMouse.ClientSize => _driver.GetClientSize();
public iOSMouse(iOSTouchDriver driver)
{
_driver = driver;
}
public Vector2 GetPosition()
{
return _driver.CurrentPosition;
}
public Vector2 GetScroll()
{
return _driver.Scroll;
}
public GamepadStateSnapshot GetMappedStateSnapshot()
{
throw new NotImplementedException();
}
public Vector3 GetMotionData(MotionInputId inputId)
{
throw new NotImplementedException();
}
public GamepadStateSnapshot GetStateSnapshot()
{
throw new NotImplementedException();
}
public (float, float) GetStick(StickInputId inputId)
{
throw new NotImplementedException();
}
public bool IsButtonPressed(MouseButton button)
{
return _driver.IsButtonPressed(button);
}
public bool IsPressed(GamepadButtonInputId inputId)
{
throw new NotImplementedException();
}
public void Rumble(float lowFrequency, float highFrequency, uint durationMs)
{
throw new NotImplementedException();
}
public void SetConfiguration(InputConfig configuration)
{
throw new NotImplementedException();
}
public void SetTriggerThreshold(float triggerThreshold)
{
throw new NotImplementedException();
}
public void Dispose()
{
_driver = null;
}
}
}

View File

@ -0,0 +1,156 @@
using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
using Ryujinx.Input;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ryujinx.Headless.SDL2
{
class iOSTouchDriver : IGamepadDriver
{
private const int CursorHideIdleTime = 5;
private bool _isDisposed;
private readonly HideCursorMode _hideCursorMode;
private bool _isHidden;
private long _lastCursorMoveTime;
public bool[] PressedButtons { get; }
public Vector2 CurrentPosition { get; private set; }
public Vector2 Scroll { get; private set; }
public Size ClientSize;
private static Dictionary<int, Vector2> _activeTouches = new();
public iOSTouchDriver(HideCursorMode hideCursorMode)
{
PressedButtons = new bool[(int)MouseButton.Count];
_hideCursorMode = hideCursorMode;
}
[UnmanagedCallersOnly(EntryPoint = "touch_began")]
public static void TouchBeganAtPoint(float x, float y, int index)
{
Vector2 position = new Vector2(x, y);
_activeTouches[index] = position;
}
[UnmanagedCallersOnly(EntryPoint = "touch_moved")]
public static void TouchMovedAtPoint(float x, float y, int index)
{
if (_activeTouches.ContainsKey(index))
{
_activeTouches[index] = new Vector2(x, y);
}
}
[UnmanagedCallersOnly(EntryPoint = "touch_ended")]
public static void TouchEndedForIndex(int index)
{
if (_activeTouches.ContainsKey(index))
{
_activeTouches.Remove(index);
}
}
public void UpdatePosition()
{
if (_activeTouches.Count > 0)
{
var touch = _activeTouches.Values.GetEnumerator();
touch.MoveNext();
Vector2 position = touch.Current;
if (CurrentPosition != position)
{
CurrentPosition = position;
_lastCursorMoveTime = Stopwatch.GetTimestamp();
}
}
CheckIdle();
}
private void CheckIdle()
{
if (_hideCursorMode != HideCursorMode.OnIdle)
{
return;
}
long cursorMoveDelta = Stopwatch.GetTimestamp() - _lastCursorMoveTime;
if (cursorMoveDelta >= CursorHideIdleTime * Stopwatch.Frequency)
{
if (!_isHidden)
{
Logger.Debug?.Print(LogClass.Application, "Hiding cursor due to inactivity.");
_isHidden = true;
}
}
else
{
if (_isHidden)
{
Logger.Debug?.Print(LogClass.Application, "Showing cursor after activity.");
_isHidden = false;
}
}
}
public void SetClientSize(int width, int height)
{
ClientSize = new Size(width, height);
}
public bool IsButtonPressed(MouseButton button)
{
if (_activeTouches.Count > 0)
{
return true;
}
return false;
}
public Size GetClientSize()
{
return ClientSize;
}
public string DriverName => "iOSTouchDriver";
public event Action<string> OnGamepadConnected
{
add { }
remove { }
}
public event Action<string> OnGamepadDisconnected
{
add { }
remove { }
}
public ReadOnlySpan<string> GamepadsIds => new[] { "0" };
public IGamepad GetGamepad(string id)
{
return new iOSMouse(this);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
}
}
}