forked from MeloNX/MeloNX
Compare commits
16 Commits
e741039304
...
1f723c0dcb
Author | SHA1 | Date | |
---|---|---|---|
1f723c0dcb | |||
cb33b04f2b | |||
500f3d5b9e | |||
ac4e5d394e | |||
f2d078f80b | |||
004a81fa60 | |||
ddf634ecb6 | |||
|
cce876c6f5 | ||
ebfb39c132 | |||
b3bb9cefcf | |||
8c54134699 | |||
e8537df246 | |||
8c6dd455f2 | |||
2a7cfa5650 | |||
df2b17ddd6 | |||
757fb1f6d1 |
@ -5,6 +5,7 @@
|
|||||||
Before you begin, ensure you have the following installed:
|
Before you begin, ensure you have the following installed:
|
||||||
|
|
||||||
- [**.NET 8.0**](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
|
- [**.NET 8.0**](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
|
||||||
|
- [**Xcode**](https://apps.apple.com/de/app/xcode/id497799835?l=en-GB&mt=12$0)
|
||||||
- A Mac running **macOS**
|
- A Mac running **macOS**
|
||||||
|
|
||||||
## Compilation Steps
|
## Compilation Steps
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
# Compatibility
|
# Compatibility
|
||||||
|
|
||||||
MeloNX works on iPhone X and later and iPad 7th Gen and later. Check out the Compatibility on the <a href="https://melonx.org/compatibility/" target="_blank">website</a>.
|
MeloNX works on iPhone XS/XR and later and iPad 8th Gen and later. Check out the Compatibility on the <a href="https://melonx.org/compatibility/" target="_blank">website</a>.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
@ -738,7 +738,7 @@
|
|||||||
"$(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.1.0;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@ -862,7 +862,7 @@
|
|||||||
"$(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.1.0;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
Binary file not shown.
@ -31,8 +31,21 @@ struct GameInfo {
|
|||||||
unsigned int ImageSize;
|
unsigned int ImageSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DlcNcaListItem {
|
||||||
|
char Path[256];
|
||||||
|
unsigned long TitleId;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DlcNcaList {
|
||||||
|
bool success;
|
||||||
|
unsigned int size;
|
||||||
|
struct DlcNcaListItem* items;
|
||||||
|
};
|
||||||
|
|
||||||
extern struct GameInfo get_game_info(int, char*);
|
extern struct GameInfo get_game_info(int, char*);
|
||||||
|
|
||||||
|
extern struct DlcNcaList get_dlc_nca_list(const char* titleIdPtr, const char* pathPtr);
|
||||||
|
|
||||||
void install_firmware(const char* inputPtr);
|
void install_firmware(const char* inputPtr);
|
||||||
|
|
||||||
char* installed_firmware_version();
|
char* installed_firmware_version();
|
||||||
@ -43,8 +56,6 @@ int main_ryujinx_sdl(int argc, char **argv);
|
|||||||
|
|
||||||
int get_current_fps();
|
int get_current_fps();
|
||||||
|
|
||||||
void set_title_update(const char* titleIdPtr, const char* updatePathPtr);
|
|
||||||
|
|
||||||
void initialize();
|
void initialize();
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
@ -0,0 +1,237 @@
|
|||||||
|
//
|
||||||
|
// NativeController.swift
|
||||||
|
// MeloNX
|
||||||
|
//
|
||||||
|
// Created by XITRIX on 15/02/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreHaptics
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
class NativeController: Hashable {
|
||||||
|
private var instanceID: SDL_JoystickID = -1
|
||||||
|
private var controller: OpaquePointer?
|
||||||
|
private var nativeController: GCController
|
||||||
|
private let controllerHaptics: CHHapticEngine?
|
||||||
|
|
||||||
|
public var controllername: String { "GC - \(nativeController.vendorName ?? "Unknown")" }
|
||||||
|
|
||||||
|
init(_ controller: GCController) {
|
||||||
|
nativeController = controller
|
||||||
|
controllerHaptics = nativeController.haptics?.createEngine(withLocality: .default)
|
||||||
|
try? controllerHaptics?.start()
|
||||||
|
setupHandheldController()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupHandheldController() {
|
||||||
|
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
||||||
|
SDL_InitSubSystem(Uint32(SDL_INIT_GAMECONTROLLER))
|
||||||
|
}
|
||||||
|
|
||||||
|
var joystickDesc = SDL_VirtualJoystickDesc(
|
||||||
|
version: UInt16(SDL_VIRTUAL_JOYSTICK_DESC_VERSION),
|
||||||
|
type: Uint16(SDL_JOYSTICK_TYPE_GAMECONTROLLER.rawValue),
|
||||||
|
naxes: 6,
|
||||||
|
nbuttons: 15,
|
||||||
|
nhats: 1,
|
||||||
|
vendor_id: 0,
|
||||||
|
product_id: 0,
|
||||||
|
padding: 0,
|
||||||
|
button_mask: 0,
|
||||||
|
axis_mask: 0,
|
||||||
|
name: (controllername as NSString).utf8String,
|
||||||
|
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
Update: { userdata in
|
||||||
|
// Update joystick state here
|
||||||
|
},
|
||||||
|
SetPlayerIndex: { userdata, playerIndex in
|
||||||
|
print("Player index set to \(playerIndex)")
|
||||||
|
},
|
||||||
|
Rumble: { userdata, lowFreq, highFreq in
|
||||||
|
print("Rumble with \(lowFreq), \(highFreq)")
|
||||||
|
guard let userdata else { return 0 }
|
||||||
|
let _self = Unmanaged<NativeController>.fromOpaque(userdata).takeUnretainedValue()
|
||||||
|
VirtualController.rumble(lowFreq: Float(lowFreq), highFreq: Float(highFreq), engine: _self.controllerHaptics)
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
RumbleTriggers: { userdata, leftRumble, rightRumble in
|
||||||
|
print("Trigger rumble with \(leftRumble), \(rightRumble)")
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
SetLED: { userdata, red, green, blue in
|
||||||
|
print("Set LED to RGB(\(red), \(green), \(blue))")
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
SendEffect: { userdata, data, size in
|
||||||
|
print("Effect sent with size \(size)")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
instanceID = SDL_JoystickAttachVirtualEx(&joystickDesc)// SDL_JoystickAttachVirtual(SDL_JoystickType(SDL_JOYSTICK_TYPE_GAMECONTROLLER.rawValue), 6, 15, 1)
|
||||||
|
if instanceID < 0 {
|
||||||
|
print("Failed to create virtual joystick: \(String(cString: SDL_GetError()))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a game controller for the virtual joystick
|
||||||
|
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||||
|
controller = SDL_GameControllerOpen(Int32(instanceID))
|
||||||
|
|
||||||
|
if controller == nil {
|
||||||
|
print("Failed to create virtual controller: \(String(cString: SDL_GetError()))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16, *) {
|
||||||
|
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.dpad.up, for: .dPadUp)
|
||||||
|
setupButtonChangeListener(gamepad.dpad.down, for: .dPadDown)
|
||||||
|
setupButtonChangeListener(gamepad.dpad.left, for: .dPadLeft)
|
||||||
|
setupButtonChangeListener(gamepad.dpad.right, for: .dPadRight)
|
||||||
|
|
||||||
|
setupButtonChangeListener(gamepad.leftShoulder, for: .leftShoulder)
|
||||||
|
setupButtonChangeListener(gamepad.rightShoulder, for: .rightShoulder)
|
||||||
|
gamepad.leftThumbstickButton.map { setupButtonChangeListener($0, for: .leftStick) }
|
||||||
|
gamepad.rightThumbstickButton.map { setupButtonChangeListener($0, for: .rightStick) }
|
||||||
|
|
||||||
|
setupButtonChangeListener(gamepad.buttonMenu, for: .start)
|
||||||
|
gamepad.buttonOptions.map { setupButtonChangeListener($0, for: .back) }
|
||||||
|
|
||||||
|
setupStickChangeListener(gamepad.leftThumbstick, for: .left)
|
||||||
|
setupStickChangeListener(gamepad.rightThumbstick, for: .right)
|
||||||
|
|
||||||
|
setupTriggerChangeListener(gamepad.leftTrigger, for: .left)
|
||||||
|
setupTriggerChangeListener(gamepad.rightTrigger, for: .right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupButtonChangeListener(_ button: GCControllerButtonInput, for key: VirtualControllerButton) {
|
||||||
|
button.valueChangedHandler = { [unowned self] _, _, pressed in
|
||||||
|
setButtonState(pressed ? 1 : 0, for: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupStickChangeListener(_ button: GCControllerDirectionPad, for key: ThumbstickType) {
|
||||||
|
button.valueChangedHandler = { [unowned self] _, xValue, yValue in
|
||||||
|
let scaledX = Sint16(xValue * 32767.0)
|
||||||
|
let scaledY = -Sint16(yValue * 32767.0)
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case .left:
|
||||||
|
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTX.rawValue))
|
||||||
|
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTY.rawValue))
|
||||||
|
case .right:
|
||||||
|
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTX.rawValue))
|
||||||
|
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTY.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTriggerChangeListener(_ button: GCControllerButtonInput, for key: ThumbstickType) {
|
||||||
|
button.valueChangedHandler = { [unowned self] _, value, pressed in
|
||||||
|
// print("Value: \(value), Is pressed: \(pressed)")
|
||||||
|
let axis: SDL_GameControllerAxis = (key == .left) ? SDL_CONTROLLER_AXIS_TRIGGERLEFT : SDL_CONTROLLER_AXIS_TRIGGERRIGHT
|
||||||
|
let scaledValue = Sint16(value * 32767.0)
|
||||||
|
updateAxisValue(value: scaledValue, forAxis: axis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func rumble(lowFreq: Float, highFreq: Float) {
|
||||||
|
do {
|
||||||
|
// Low-frequency haptic pattern
|
||||||
|
let lowFreqPattern = try CHHapticPattern(events: [
|
||||||
|
CHHapticEvent(eventType: .hapticTransient, parameters: [
|
||||||
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: lowFreq),
|
||||||
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
|
||||||
|
], relativeTime: 0, duration: 0.2)
|
||||||
|
], parameters: [])
|
||||||
|
|
||||||
|
// High-frequency haptic pattern
|
||||||
|
let highFreqPattern = try CHHapticPattern(events: [
|
||||||
|
CHHapticEvent(eventType: .hapticTransient, parameters: [
|
||||||
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: highFreq),
|
||||||
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
|
||||||
|
], relativeTime: 0.2, duration: 0.2)
|
||||||
|
], parameters: [])
|
||||||
|
|
||||||
|
// Create and start the haptic engine
|
||||||
|
let engine = try CHHapticEngine()
|
||||||
|
try engine.start()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Error creating haptic patterns: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func updateAxisValue(value: Sint16, forAxis axis: SDL_GameControllerAxis) {
|
||||||
|
guard controller != nil else { return }
|
||||||
|
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||||
|
SDL_JoystickSetVirtualAxis(joystick, axis.rawValue, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thumbstickMoved(_ stick: ThumbstickType, x: Double, y: Double) {
|
||||||
|
let scaleFactor = 32767.0 / 160
|
||||||
|
|
||||||
|
let scaledX = Int16(min(32767.0, max(-32768.0, x * scaleFactor)))
|
||||||
|
let scaledY = Int16(min(32767.0, max(-32768.0, y * scaleFactor)))
|
||||||
|
|
||||||
|
if stick == .right {
|
||||||
|
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTX.rawValue))
|
||||||
|
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTY.rawValue))
|
||||||
|
} else { // ThumbstickType.left
|
||||||
|
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTX.rawValue))
|
||||||
|
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTY.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setButtonState(_ state: Uint8, for button: VirtualControllerButton) {
|
||||||
|
guard controller != nil else { return }
|
||||||
|
|
||||||
|
// print("Button: \(button.rawValue) {state: \(state)}")
|
||||||
|
if (button == .leftTrigger || button == .rightTrigger) && (state == 1 || state == 0) {
|
||||||
|
let axis: SDL_GameControllerAxis = (button == .leftTrigger) ? SDL_CONTROLLER_AXIS_TRIGGERLEFT : SDL_CONTROLLER_AXIS_TRIGGERRIGHT
|
||||||
|
let value: Int = (state == 1) ? 32767 : 0
|
||||||
|
updateAxisValue(value: Sint16(value), forAxis: axis)
|
||||||
|
} else {
|
||||||
|
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||||
|
SDL_JoystickSetVirtualButton(joystick, Int32(button.rawValue), state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
if let controller {
|
||||||
|
SDL_JoystickDetachVirtual(instanceID)
|
||||||
|
SDL_GameControllerClose(controller)
|
||||||
|
self.controller = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(nativeController)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: NativeController, rhs: NativeController) -> Bool {
|
||||||
|
lhs.nativeController == rhs.nativeController
|
||||||
|
}
|
||||||
|
}
|
@ -78,7 +78,7 @@ class VirtualController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func rumble(lowFreq: Float, highFreq: Float) {
|
static func rumble(lowFreq: Float, highFreq: Float, engine: CHHapticEngine? = nil) {
|
||||||
do {
|
do {
|
||||||
// Low-frequency haptic pattern
|
// Low-frequency haptic pattern
|
||||||
let lowFreqPattern = try CHHapticPattern(events: [
|
let lowFreqPattern = try CHHapticPattern(events: [
|
||||||
@ -96,9 +96,23 @@ class VirtualController {
|
|||||||
], relativeTime: 0.2, duration: 0.2)
|
], relativeTime: 0.2, duration: 0.2)
|
||||||
], parameters: [])
|
], parameters: [])
|
||||||
|
|
||||||
|
// Mutable engine
|
||||||
|
var engine = engine
|
||||||
|
|
||||||
|
// If no engine passed, use device engine
|
||||||
|
if engine == nil {
|
||||||
// Create and start the haptic engine
|
// Create and start the haptic engine
|
||||||
let engine = try CHHapticEngine()
|
if hapticEngine == nil {
|
||||||
try engine.start()
|
hapticEngine = try CHHapticEngine()
|
||||||
|
try hapticEngine?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = hapticEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let engine else {
|
||||||
|
return print("Error creating haptic patterns: hapticEngine is nil")
|
||||||
|
}
|
||||||
|
|
||||||
// Create and play the low-frequency player
|
// Create and play the low-frequency player
|
||||||
let lowFreqPlayer = try engine.makePlayer(with: lowFreqPattern)
|
let lowFreqPlayer = try engine.makePlayer(with: lowFreqPattern)
|
||||||
@ -113,6 +127,8 @@ class VirtualController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var hapticEngine: CHHapticEngine?
|
||||||
|
|
||||||
|
|
||||||
func updateAxisValue(value: Sint16, forAxis axis: SDL_GameControllerAxis) {
|
func updateAxisValue(value: Sint16, forAxis axis: SDL_GameControllerAxis) {
|
||||||
guard controller != nil else { return }
|
guard controller != nil else { return }
|
||||||
|
@ -366,16 +366,27 @@ class Ryujinx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDlcNcaList(titleId: String, path: String) -> [DownloadableContentNca] {
|
||||||
func setTitleUpdate(titleId: String, updatePath: String) {
|
guard let titleIdCString = titleId.cString(using: .utf8),
|
||||||
guard let titleIdPtr = titleId.cString(using: .utf8),
|
let pathCString = path.cString(using: .utf8)
|
||||||
let updatePathPtr = updatePath.cString(using: .utf8)
|
|
||||||
else {
|
else {
|
||||||
print("Invalid firmware path")
|
print("Invalid path")
|
||||||
return
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
set_title_update(titleIdPtr, updatePathPtr)
|
let listPointer = get_dlc_nca_list(titleIdCString, pathCString)
|
||||||
|
print("DLC parcing success: \(listPointer.success)")
|
||||||
|
guard listPointer.success else { return [] }
|
||||||
|
|
||||||
|
let list = Array(UnsafeBufferPointer(start: listPointer.items, count: Int(listPointer.size)))
|
||||||
|
|
||||||
|
return list.map { item in
|
||||||
|
.init(fullPath: withUnsafePointer(to: item.Path) {
|
||||||
|
$0.withMemoryRebound(to: UInt8.self, capacity: MemoryLayout.size(ofValue: $0)) {
|
||||||
|
String(cString: $0)
|
||||||
|
}
|
||||||
|
}, titleId: item.TitleId, enabled: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func generateGamepadId(joystickIndex: Int32) -> String? {
|
private func generateGamepadId(joystickIndex: Int32) -> String? {
|
||||||
|
@ -9,7 +9,7 @@ import SwiftUI
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
public struct Game: Identifiable, Equatable, Hashable {
|
public struct Game: Identifiable, Equatable, Hashable {
|
||||||
public var id = UUID()
|
public var id: String { titleId }
|
||||||
|
|
||||||
var containerFolder: URL
|
var containerFolder: URL
|
||||||
var fileType: UTType
|
var fileType: UTType
|
||||||
@ -21,7 +21,6 @@ public struct Game: Identifiable, Equatable, Hashable {
|
|||||||
var version: String
|
var version: String
|
||||||
var icon: UIImage?
|
var icon: UIImage?
|
||||||
|
|
||||||
|
|
||||||
static func convertGameInfoToGame(gameInfo: GameInfo, url: URL) -> Game {
|
static func convertGameInfoToGame(gameInfo: GameInfo, url: URL) -> Game {
|
||||||
var gameInfo = gameInfo
|
var gameInfo = gameInfo
|
||||||
var gameTemp = Game(containerFolder: url.deletingLastPathComponent(), fileType: .item, fileURL: url, titleName: "", titleId: "", developer: "", version: "")
|
var gameTemp = Game(containerFolder: url.deletingLastPathComponent(), fileType: .item, fileURL: url, titleName: "", titleId: "", developer: "", version: "")
|
||||||
|
@ -26,6 +26,7 @@ struct ContentView: View {
|
|||||||
@State private var controllersList: [Controller] = []
|
@State private var controllersList: [Controller] = []
|
||||||
@State private var currentControllers: [Controller] = []
|
@State private var currentControllers: [Controller] = []
|
||||||
@State var onscreencontroller: Controller = Controller(id: "", name: "")
|
@State var onscreencontroller: Controller = Controller(id: "", name: "")
|
||||||
|
@State var nativeControllers: [GCController: NativeController] = [:]
|
||||||
@State private var isVirtualControllerActive: Bool = false
|
@State private var isVirtualControllerActive: Bool = false
|
||||||
@AppStorage("isVirtualController") var isVCA: Bool = true
|
@AppStorage("isVirtualController") var isVCA: Bool = true
|
||||||
|
|
||||||
@ -152,6 +153,7 @@ struct ContentView: View {
|
|||||||
queue: .main) { notification in
|
queue: .main) { notification in
|
||||||
if let controller = notification.object as? GCController {
|
if let controller = notification.object as? GCController {
|
||||||
print("Controller connected: \(controller.productCategory)")
|
print("Controller connected: \(controller.productCategory)")
|
||||||
|
nativeControllers[controller] = .init(controller)
|
||||||
refreshControllersList()
|
refreshControllersList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,6 +165,8 @@ struct ContentView: View {
|
|||||||
queue: .main) { notification in
|
queue: .main) { notification in
|
||||||
if let controller = notification.object as? GCController {
|
if let controller = notification.object as? GCController {
|
||||||
print("Controller disconnected: \(controller.productCategory)")
|
print("Controller disconnected: \(controller.productCategory)")
|
||||||
|
nativeControllers[controller]?.cleanup()
|
||||||
|
nativeControllers[controller] = nil
|
||||||
refreshControllersList()
|
refreshControllersList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,7 +310,8 @@ struct ContentView: View {
|
|||||||
self.onscreencontroller = onscreen
|
self.onscreencontroller = onscreen
|
||||||
}
|
}
|
||||||
|
|
||||||
controllersList.removeAll(where: { $0.id == "0"})
|
controllersList.removeAll(where: { $0.id == "0" || (!$0.name.starts(with: "GC - ") && $0 != onscreencontroller) })
|
||||||
|
controllersList.mutableForEach { $0.name = $0.name.replacingOccurrences(of: "GC - ", with: "") }
|
||||||
|
|
||||||
currentControllers = []
|
currentControllers = []
|
||||||
|
|
||||||
@ -397,3 +402,10 @@ func loadSettings() -> Ryujinx.Configuration? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
@inlinable public mutating func mutableForEach(_ body: (inout Element) throws -> Void) rethrows {
|
||||||
|
for index in self.indices {
|
||||||
|
try body(&self[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -14,7 +14,10 @@ struct GameInfoSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
iOSNav {
|
iOSNav {
|
||||||
VStack {
|
List {
|
||||||
|
Section {}
|
||||||
|
header: {
|
||||||
|
VStack(alignment: .center) {
|
||||||
if let icon = game.icon {
|
if let icon = game.icon {
|
||||||
Image(uiImage: icon)
|
Image(uiImage: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
@ -36,23 +39,27 @@ struct GameInfoSheet: View {
|
|||||||
.frame(width: 150, height: 150)
|
.frame(width: 150, height: 150)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
VStack(alignment: .center) {
|
||||||
VStack(alignment: .leading) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text("**\(game.titleName)** | \(game.titleId.capitalized)")
|
Text("**\(game.titleName)** | \(game.titleId.capitalized)")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
Text(game.developer)
|
Text(game.developer)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
Section {
|
||||||
Text("Information")
|
HStack {
|
||||||
.font(.title2)
|
Text("**Version**")
|
||||||
.bold()
|
Spacer()
|
||||||
|
Text(game.version)
|
||||||
Text("**Version:** \(game.version)")
|
.foregroundStyle(Color.secondary)
|
||||||
Text("**Title ID:** \(game.titleId)")
|
}
|
||||||
|
HStack {
|
||||||
|
Text("**Title ID**")
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button {
|
Button {
|
||||||
UIPasteboard.general.string = game.titleId
|
UIPasteboard.general.string = game.titleId
|
||||||
@ -60,15 +67,32 @@ struct GameInfoSheet: View {
|
|||||||
Text("Copy Title ID")
|
Text("Copy Title ID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("**Game Size:** \(fetchFileSize(for: game.fileURL) ?? 0) bytes")
|
|
||||||
Text("**File Type:** .\(getFileType(game.fileURL))")
|
|
||||||
Text("**Game URL:** \(trimGameURL(game.fileURL))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text(game.titleId)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("**Game Size**")
|
||||||
|
Spacer()
|
||||||
|
Text("\(fetchFileSize(for: game.fileURL) ?? 0) bytes")
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("**File Type**")
|
||||||
|
Spacer()
|
||||||
|
Text(getFileType(game.fileURL))
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("**Game URL**")
|
||||||
|
Text(trimGameURL(game.fileURL))
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Information")
|
||||||
|
}
|
||||||
|
.headerProminence(.increased)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 5)
|
|
||||||
.navigationTitle(game.titleName)
|
.navigationTitle(game.titleName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -103,10 +127,6 @@ struct GameInfoSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getFileType(_ url: URL) -> String {
|
func getFileType(_ url: URL) -> String {
|
||||||
let path = url.path
|
url.pathExtension
|
||||||
if let range = path.range(of: ".") {
|
|
||||||
return String(path[range.upperBound...])
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ struct GameLibraryView: View {
|
|||||||
@State var isSelectingGameFile = false
|
@State var isSelectingGameFile = false
|
||||||
@State var isViewingGameInfo: Bool = false
|
@State var isViewingGameInfo: Bool = false
|
||||||
@State var isSelectingGameUpdate: Bool = false
|
@State var isSelectingGameUpdate: Bool = false
|
||||||
|
@State var isSelectingGameDLC: Bool = false
|
||||||
@State var gameInfo: Game?
|
@State var gameInfo: Game?
|
||||||
var games: Binding<[Game]> {
|
var games: Binding<[Game]> {
|
||||||
Binding(
|
Binding(
|
||||||
@ -38,25 +39,26 @@ struct GameLibraryView: View {
|
|||||||
|
|
||||||
var filteredGames: [Game] {
|
var filteredGames: [Game] {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
return Ryujinx.shared.games
|
return Ryujinx.shared.games.filter { game in
|
||||||
|
!realRecentGames.contains(where: { $0.titleId == game.titleId })
|
||||||
}
|
}
|
||||||
return Ryujinx.shared.games.filter {
|
}
|
||||||
$0.titleName.localizedCaseInsensitiveContains(searchText) ||
|
return Ryujinx.shared.games.filter { game in
|
||||||
$0.developer.localizedCaseInsensitiveContains(searchText)
|
game.titleName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
game.developer.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var realRecentGames: [Game] {
|
||||||
|
let games = Ryujinx.shared.games
|
||||||
|
return recentGames.compactMap { recentGame in
|
||||||
|
games.first(where: { $0.titleId == recentGame.titleId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
iOSNav {
|
iOSNav {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(alignment: .leading, spacing: 20) {
|
|
||||||
if !isSearching {
|
|
||||||
Text("Games")
|
|
||||||
.font(.system(size: 34, weight: .bold))
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
if Ryujinx.shared.games.isEmpty {
|
if Ryujinx.shared.games.isEmpty {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "gamecontroller.fill")
|
Image(systemName: "gamecontroller.fill")
|
||||||
@ -73,52 +75,38 @@ struct GameLibraryView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
if !isSearching && !recentGames.isEmpty {
|
if !isSearching && !realRecentGames.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Section {
|
||||||
|
ForEach(realRecentGames) { game in
|
||||||
|
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, isSelectingGameDLC: $isSelectingGameDLC, gameInfo: $gameInfo)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
removeFromRecentGames(game)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
Text("Recent")
|
Text("Recent")
|
||||||
.font(.title2.bold())
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
LazyHStack(spacing: 16) {
|
|
||||||
ForEach(recentGames) { game in
|
|
||||||
RecentGameCard(game: game, startemu: $startemu)
|
|
||||||
.onTapGesture {
|
|
||||||
addToRecentGames(game)
|
|
||||||
startemu = game
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Section {
|
||||||
Text("All Games")
|
|
||||||
.font(.title2.bold())
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
LazyVStack(spacing: 2) {
|
|
||||||
ForEach(filteredGames) { game in
|
ForEach(filteredGames) { game in
|
||||||
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, gameInfo: $gameInfo)
|
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, isSelectingGameDLC: $isSelectingGameDLC, gameInfo: $gameInfo)
|
||||||
.onTapGesture {
|
|
||||||
addToRecentGames(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Others")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyVStack(spacing: 2) {
|
|
||||||
ForEach(filteredGames) { game in
|
ForEach(filteredGames) { game in
|
||||||
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, gameInfo: $gameInfo)
|
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, isSelectingGameDLC: $isSelectingGameDLC, gameInfo: $gameInfo)
|
||||||
.onTapGesture {
|
|
||||||
addToRecentGames(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Games")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadRecentGames()
|
loadRecentGames()
|
||||||
|
|
||||||
@ -143,9 +131,8 @@ struct GameLibraryView: View {
|
|||||||
print(error)
|
print(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
isSelectingGameFile.toggle()
|
isSelectingGameFile.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
@ -214,9 +201,13 @@ struct GameLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: startemu) { game in
|
||||||
|
guard let game else { return }
|
||||||
|
addToRecentGames(game)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
.searchable(text: $searchText)
|
.searchable(text: $searchText)
|
||||||
|
.animation(.easeInOut, value: searchText)
|
||||||
.onChange(of: searchText) { _ in
|
.onChange(of: searchText) { _ in
|
||||||
isSearching = !searchText.isEmpty
|
isSearching = !searchText.isEmpty
|
||||||
}
|
}
|
||||||
@ -281,6 +272,9 @@ struct GameLibraryView: View {
|
|||||||
.sheet(isPresented: $isSelectingGameUpdate) {
|
.sheet(isPresented: $isSelectingGameUpdate) {
|
||||||
UpdateManagerSheet(game: $gameInfo)
|
UpdateManagerSheet(game: $gameInfo)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isSelectingGameDLC) {
|
||||||
|
DLCManagerSheet(game: $gameInfo)
|
||||||
|
}
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { isViewingGameInfo && gameInfo != nil },
|
get: { isViewingGameInfo && gameInfo != nil },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
@ -296,9 +290,8 @@ struct GameLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func addToRecentGames(_ game: Game) {
|
private func addToRecentGames(_ game: Game) {
|
||||||
recentGames.removeAll { $0.id == game.id }
|
recentGames.removeAll { $0.titleId == game.titleId }
|
||||||
|
|
||||||
recentGames.insert(game, at: 0)
|
recentGames.insert(game, at: 0)
|
||||||
|
|
||||||
@ -309,6 +302,11 @@ struct GameLibraryView: View {
|
|||||||
saveRecentGames()
|
saveRecentGames()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func removeFromRecentGames(_ game: Game) {
|
||||||
|
recentGames.removeAll { $0.titleId == game.titleId }
|
||||||
|
saveRecentGames()
|
||||||
|
}
|
||||||
|
|
||||||
private func saveRecentGames() {
|
private func saveRecentGames() {
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
@ -329,8 +327,7 @@ struct GameLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Game Function
|
||||||
// MARK: - Delete Game Function
|
|
||||||
func deleteGame(game: Game) {
|
func deleteGame(game: Game) {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
do {
|
do {
|
||||||
@ -343,7 +340,7 @@ struct GameLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -Game Model
|
// MARK: - Game Model
|
||||||
extension Game: Codable {
|
extension Game: Codable {
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case titleName, titleId, developer, version, fileURL
|
case titleName, titleId, developer, version, fileURL
|
||||||
@ -372,60 +369,14 @@ extension Game: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -Recent Game Card
|
// MARK: - Game List Item
|
||||||
struct RecentGameCard: View {
|
|
||||||
let game: Game
|
|
||||||
@Binding var startemu: Game?
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: {
|
|
||||||
startemu = game
|
|
||||||
}) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if let icon = game.icon {
|
|
||||||
Image(uiImage: icon)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 140, height: 140)
|
|
||||||
.cornerRadius(12)
|
|
||||||
} else {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(colorScheme == .dark ?
|
|
||||||
Color(.systemGray5) : Color(.systemGray6))
|
|
||||||
.frame(width: 140, height: 140)
|
|
||||||
|
|
||||||
Image(systemName: "gamecontroller.fill")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(game.titleName)
|
|
||||||
.font(.subheadline.bold())
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(game.developer)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -Game List Item
|
|
||||||
struct GameListRow: View {
|
struct GameListRow: View {
|
||||||
let game: Game
|
let game: Game
|
||||||
@Binding var startemu: Game?
|
@Binding var startemu: Game?
|
||||||
@Binding var games: [Game] // Add this binding
|
@Binding var games: [Game] // Add this binding
|
||||||
@Binding var isViewingGameInfo: Bool
|
@Binding var isViewingGameInfo: Bool
|
||||||
@Binding var isSelectingGameUpdate: Bool
|
@Binding var isSelectingGameUpdate: Bool
|
||||||
|
@Binding var isSelectingGameDLC: Bool
|
||||||
@Binding var gameInfo: Game?
|
@Binding var gameInfo: Game?
|
||||||
@State var gametoDelete: Game?
|
@State var gametoDelete: Game?
|
||||||
@State var showGameDeleteConfirmation: Bool = false
|
@State var showGameDeleteConfirmation: Bool = false
|
||||||
@ -474,9 +425,7 @@ struct GameListRow: View {
|
|||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
@ -491,13 +440,22 @@ struct GameListRow: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Game Info", systemImage: "info.circle")
|
Label("Game Info", systemImage: "info.circle")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
Button {
|
Button {
|
||||||
gameInfo = game
|
gameInfo = game
|
||||||
isSelectingGameUpdate.toggle()
|
isSelectingGameUpdate.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Game Update Manager", systemImage: "chevron.up.circle")
|
Label("Game Update Manager", systemImage: "chevron.up.circle")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
gameInfo = game
|
||||||
|
isSelectingGameDLC.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Game DLC Manager", systemImage: "plus.viewfinder")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@ -509,8 +467,6 @@ struct GameListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.confirmationDialog("Are you sure you want to delete this game?", isPresented: $showGameDeleteConfirmation) {
|
.confirmationDialog("Are you sure you want to delete this game?", isPresented: $showGameDeleteConfirmation) {
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let game = gametoDelete {
|
if let game = gametoDelete {
|
||||||
@ -533,4 +489,3 @@ struct GameListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
159
src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift
Normal file
159
src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
//
|
||||||
|
// GameDLCManagerSheet.swift
|
||||||
|
// MeloNX
|
||||||
|
//
|
||||||
|
// Created by XITRIX on 16/02/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct DownloadableContentNca: Codable, Hashable {
|
||||||
|
var fullPath: String
|
||||||
|
var titleId: UInt
|
||||||
|
var enabled: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fullPath = "path"
|
||||||
|
case titleId = "title_id"
|
||||||
|
case enabled = "is_enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloadableContentContainer: Codable, Hashable {
|
||||||
|
var containerPath: String
|
||||||
|
var downloadableContentNcaList: [DownloadableContentNca]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case containerPath = "path"
|
||||||
|
case downloadableContentNcaList = "dlc_nca_list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DLCManagerSheet: View {
|
||||||
|
@Binding var game: Game!
|
||||||
|
@State private var isSelectingGameDLC = false
|
||||||
|
@State private var dlcs: [DownloadableContentContainer] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
let withIndex = dlcs.enumerated().map { $0 }
|
||||||
|
List(withIndex, id: \.element.containerPath) { index, dlc in
|
||||||
|
Button(action: {
|
||||||
|
let toggle = dlcs[index].downloadableContentNcaList.first?.enabled ?? true
|
||||||
|
dlcs[index].downloadableContentNcaList.mutableForEach { $0.enabled = !toggle }
|
||||||
|
Self.saveDlcs(game, dlc: dlcs)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text((dlc.containerPath as NSString).lastPathComponent)
|
||||||
|
.foregroundStyle(Color(uiColor: .label))
|
||||||
|
Spacer()
|
||||||
|
if dlc.downloadableContentNcaList.first?.enabled == true {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.font(.system(size: 24))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
||||||
|
.font(.system(size: 24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
let path = URL.documentsDirectory.appendingPathComponent(dlc.containerPath)
|
||||||
|
try? FileManager.default.removeItem(atPath: path.path)
|
||||||
|
dlcs.remove(at: index)
|
||||||
|
Self.saveDlcs(game, dlc: dlcs)
|
||||||
|
} label: {
|
||||||
|
Text("Remove DLC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(game.titleName) DLCs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
Button("Add", systemImage: "plus") {
|
||||||
|
isSelectingGameDLC = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
dlcs = Self.loadDlc(game)
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $isSelectingGameDLC, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
for url in urls {
|
||||||
|
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 dlcDirectory = documentsDirectory.appendingPathComponent("dlc")
|
||||||
|
let romDlcDirectory = dlcDirectory.appendingPathComponent(game.titleId)
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: dlcDirectory.path) {
|
||||||
|
try fileManager.createDirectory(at: dlcDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: romDlcDirectory.path) {
|
||||||
|
try fileManager.createDirectory(at: romDlcDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dlcContent = Ryujinx.shared.getDlcNcaList(titleId: game.titleId, path: url.path)
|
||||||
|
guard !dlcContent.isEmpty else { return }
|
||||||
|
|
||||||
|
let destinationURL = romDlcDirectory.appendingPathComponent(url.lastPathComponent)
|
||||||
|
try? fileManager.copyItem(at: url, to: destinationURL)
|
||||||
|
|
||||||
|
let container = DownloadableContentContainer(
|
||||||
|
containerPath: Self.relativeDlcDirectoryPath(for: game, dlcPath: destinationURL),
|
||||||
|
downloadableContentNcaList: dlcContent
|
||||||
|
)
|
||||||
|
dlcs.append(container)
|
||||||
|
|
||||||
|
Self.saveDlcs(game, dlc: dlcs)
|
||||||
|
} catch {
|
||||||
|
print("Error copying game file: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let err):
|
||||||
|
print("File import failed: \(err.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension DLCManagerSheet {
|
||||||
|
static func loadDlc(_ game: Game) -> [DownloadableContentContainer] {
|
||||||
|
let jsonURL = dlcJsonPath(for: game)
|
||||||
|
guard let data = try? Data(contentsOf: jsonURL),
|
||||||
|
var result = try? JSONDecoder().decode([DownloadableContentContainer].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
result = result.filter { container in
|
||||||
|
let path = URL.documentsDirectory.appendingPathComponent(container.containerPath)
|
||||||
|
return FileManager.default.fileExists(atPath: path.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDlcs(_ game: Game, dlc: [DownloadableContentContainer]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(dlc) else { return }
|
||||||
|
try? data.write(to: dlcJsonPath(for: game))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relativeDlcDirectoryPath(for game: Game, dlcPath: URL) -> String {
|
||||||
|
"dlc/\(game.titleId)/\(dlcPath.lastPathComponent)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dlcJsonPath(for game: Game) -> URL {
|
||||||
|
URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game.titleId).appendingPathComponent("dlc.json")
|
||||||
|
}
|
||||||
|
}
|
@ -18,16 +18,22 @@ struct UpdateManagerSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack {
|
List(paths, id: \..self, selection: $selectedItem) { item in
|
||||||
List(paths, id: \..self) { item in
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectItem(item.lastPathComponent)
|
selectItem(item.lastPathComponent)
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.lastPathComponent)
|
Text(item.lastPathComponent)
|
||||||
if selectedItem == "\(game!.titleId)/\(item.lastPathComponent)" {
|
.foregroundStyle(Color(uiColor: .label))
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "checkmark")
|
if selectedItem == "updates/\(game!.titleId)/\(item.lastPathComponent)" {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.font(.system(size: 24))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
||||||
|
.font(.system(size: 24))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,15 +45,15 @@ struct UpdateManagerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.onAppear {
|
||||||
.onAppear() {
|
|
||||||
print(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
print(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
||||||
|
|
||||||
loadJSON(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
loadJSON(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
||||||
}
|
}
|
||||||
.navigationTitle("\(game!.titleName) Updates")
|
.navigationTitle("\(game!.titleName) Updates")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
Button("+") {
|
Button("Add", systemImage: "plus") {
|
||||||
isSelectingGameUpdate = true
|
isSelectingGameUpdate = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +86,8 @@ struct UpdateManagerSheet: View {
|
|||||||
let destinationURL = romUpdatedDirectory.appendingPathComponent(url.lastPathComponent)
|
let destinationURL = romUpdatedDirectory.appendingPathComponent(url.lastPathComponent)
|
||||||
try? fileManager.copyItem(at: url, to: destinationURL)
|
try? fileManager.copyItem(at: url, to: destinationURL)
|
||||||
|
|
||||||
Ryujinx.shared.setTitleUpdate(titleId: gameInfo.titleId, updatePath: "\(gameInfo.titleId)/" + url.lastPathComponent)
|
items.append("updates/" + gameInfo.titleId + "/" + url.lastPathComponent)
|
||||||
|
selectItem(url.lastPathComponent)
|
||||||
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||||
loadJSON(jsonURL!)
|
loadJSON(jsonURL!)
|
||||||
} catch {
|
} catch {
|
||||||
@ -93,7 +100,7 @@ struct UpdateManagerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeUpdate(_ game: URL) {
|
func removeUpdate(_ game: URL) {
|
||||||
let gameString = "\(self.game!.titleId)/\(game.lastPathComponent)"
|
let gameString = "updates/\(self.game!.titleId)/\(game.lastPathComponent)"
|
||||||
paths.removeAll { $0 == game }
|
paths.removeAll { $0 == game }
|
||||||
items.removeAll { $0 == gameString }
|
items.removeAll { $0 == gameString }
|
||||||
|
|
||||||
@ -108,6 +115,7 @@ struct UpdateManagerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveJSON(selectedItem: selectedItem ?? "")
|
saveJSON(selectedItem: selectedItem ?? "")
|
||||||
|
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveJSON(selectedItem: String) {
|
func saveJSON(selectedItem: String) {
|
||||||
@ -122,26 +130,28 @@ struct UpdateManagerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadJSON(_ json: URL) {
|
func loadJSON(_ json: URL) {
|
||||||
|
|
||||||
self.jsonURL = json
|
self.jsonURL = json
|
||||||
print("Failed to read JSO")
|
|
||||||
|
|
||||||
guard let jsonURL = jsonURL else { return }
|
guard let jsonURL else { return }
|
||||||
print("Failed to read JSOK")
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: jsonURL)
|
let data = try Data(contentsOf: jsonURL)
|
||||||
if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||||
let list = jsonDict["paths"] as? [String] {
|
let list = jsonDict["paths"] as? [String]
|
||||||
var urls: [URL] = []
|
{
|
||||||
|
|
||||||
for path in list {
|
let filteredList = list.filter { relativePath in
|
||||||
urls.append(URL.documentsDirectory.appendingPathComponent("updates").appendingPathComponent(path))
|
let path = URL.documentsDirectory.appendingPathComponent(relativePath)
|
||||||
|
return FileManager.default.fileExists(atPath: path.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.items = list
|
let urls: [URL] = filteredList.map { relativePath in
|
||||||
self.paths = urls
|
URL.documentsDirectory.appendingPathComponent(relativePath)
|
||||||
self.selectedItem = jsonDict["selected"] as? String
|
}
|
||||||
|
|
||||||
|
items = filteredList
|
||||||
|
paths = urls
|
||||||
|
selectedItem = jsonDict["selected"] as? String
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to read JSON: \(error)")
|
print("Failed to read JSON: \(error)")
|
||||||
@ -155,17 +165,17 @@ struct UpdateManagerSheet: View {
|
|||||||
do {
|
do {
|
||||||
let newData = try JSONSerialization.data(withJSONObject: defaultData, options: .prettyPrinted)
|
let newData = try JSONSerialization.data(withJSONObject: defaultData, options: .prettyPrinted)
|
||||||
try newData.write(to: jsonURL)
|
try newData.write(to: jsonURL)
|
||||||
self.items = []
|
items = []
|
||||||
self.selectedItem = ""
|
selectedItem = ""
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create default JSON: \(error)")
|
print("Failed to create default JSON: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectItem(_ item: String) {
|
func selectItem(_ item: String) {
|
||||||
let newSelection = "\(game!.titleId)/\(item)"
|
let newSelection = "updates/\(game!.titleId)/\(item)"
|
||||||
|
|
||||||
guard let jsonURL = jsonURL else { return }
|
guard let jsonURL else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: jsonURL)
|
let data = try Data(contentsOf: jsonURL)
|
||||||
@ -175,7 +185,7 @@ struct UpdateManagerSheet: View {
|
|||||||
jsonDict["selected"] = ""
|
jsonDict["selected"] = ""
|
||||||
selectedItem = ""
|
selectedItem = ""
|
||||||
} else {
|
} else {
|
||||||
jsonDict["selected"] = newSelection
|
jsonDict["selected"] = "\(newSelection)"
|
||||||
selectedItem = newSelection
|
selectedItem = newSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,9 +193,9 @@ struct UpdateManagerSheet: View {
|
|||||||
|
|
||||||
let newData = try JSONSerialization.data(withJSONObject: jsonDict, options: .prettyPrinted)
|
let newData = try JSONSerialization.data(withJSONObject: jsonDict, options: .prettyPrinted)
|
||||||
try newData.write(to: jsonURL)
|
try newData.write(to: jsonURL)
|
||||||
|
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to update JSON: \(error)")
|
print("Failed to update JSON: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -142,34 +142,95 @@ namespace Ryujinx.Headless.SDL2
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "set_title_update")]
|
[UnmanagedCallersOnly(EntryPoint = "get_dlc_nca_list")]
|
||||||
public static unsafe void SetTitleUpdate(IntPtr titleIdPtr, IntPtr updatePathPtr) {
|
public static unsafe DlcNcaList GetDlcNcaList(IntPtr titleIdPtr, IntPtr pathPtr)
|
||||||
|
{
|
||||||
var titleId = Marshal.PtrToStringAnsi(titleIdPtr);
|
var titleId = Marshal.PtrToStringAnsi(titleIdPtr);
|
||||||
var updatePath = Marshal.PtrToStringAnsi(updatePathPtr);
|
var containerPath = Marshal.PtrToStringAnsi(pathPtr);
|
||||||
string _updateJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleId, "updates.json");
|
|
||||||
|
|
||||||
TitleUpdateMetadata _titleUpdateWindowData;
|
if (!File.Exists(containerPath))
|
||||||
|
{
|
||||||
if (File.Exists(_updateJsonPath)) {
|
return new DlcNcaList { success = false };
|
||||||
_titleUpdateWindowData = JsonHelper.DeserializeFromFile<TitleUpdateMetadata>(_updateJsonPath, _titleSerializerContext.TitleUpdateMetadata);
|
|
||||||
|
|
||||||
_titleUpdateWindowData.Paths ??= new List<string>();
|
|
||||||
if (!_titleUpdateWindowData.Paths.Contains(updatePath)) {
|
|
||||||
_titleUpdateWindowData.Paths.Add(updatePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_titleUpdateWindowData.Selected = updatePath;
|
using FileStream containerFile = File.OpenRead(containerPath);
|
||||||
} else {
|
|
||||||
_titleUpdateWindowData = new TitleUpdateMetadata {
|
PartitionFileSystem pfs = new();
|
||||||
Selected = updatePath,
|
pfs.Initialize(containerFile.AsStorage()).ThrowIfFailure();
|
||||||
Paths = new List<string> { updatePath },
|
bool containsDlc = false;
|
||||||
};
|
|
||||||
|
_virtualFileSystem.ImportTickets(pfs);
|
||||||
|
|
||||||
|
// TreeIter? parentIter = null;
|
||||||
|
|
||||||
|
List<DlcNcaListItem> listItems = new();
|
||||||
|
foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca"))
|
||||||
|
{
|
||||||
|
using var ncaFile = new UniqueRef<IFile>();
|
||||||
|
|
||||||
|
pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
|
||||||
|
Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), containerPath);
|
||||||
|
|
||||||
|
if (nca == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonHelper.SerializeToFile(_updateJsonPath, _titleUpdateWindowData, _titleSerializerContext.TitleUpdateMetadata);
|
if (nca.Header.ContentType == NcaContentType.PublicData)
|
||||||
|
{
|
||||||
|
if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000).ToString("x16") != titleId)
|
||||||
|
{
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.Warning?.Print(LogClass.Application, $"ContainerPath: {containerPath}");
|
||||||
|
Logger.Warning?.Print(LogClass.Application, $"TitleId: {nca.Header.TitleId}");
|
||||||
|
Logger.Warning?.Print(LogClass.Application, $"fileEntry.FullPath: {fileEntry.FullPath}");
|
||||||
|
|
||||||
|
// parentIter ??= ((TreeStore)_dlcTreeView.Model).AppendValues(true, "", containerPath);
|
||||||
|
// ((TreeStore)_dlcTreeView.Model).AppendValues(parentIter.Value, true, nca.Header.TitleId.ToString("X16"), fileEntry.FullPath);
|
||||||
|
|
||||||
|
DlcNcaListItem item = new();
|
||||||
|
CopyStringToFixedArray(fileEntry.FullPath, item.Path, 256);
|
||||||
|
item.TitleId = nca.Header.TitleId;
|
||||||
|
listItems.Add(item);
|
||||||
|
|
||||||
|
containsDlc = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containsDlc)
|
||||||
|
{
|
||||||
|
return new DlcNcaList { success = false };
|
||||||
|
// GtkDialog.CreateErrorDialog("The specified file does not contain DLC for the selected title!");
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new DlcNcaList { success = true, size = (uint) listItems.Count };
|
||||||
|
|
||||||
|
DlcNcaListItem[] items = listItems.ToArray();
|
||||||
|
|
||||||
|
fixed (DlcNcaListItem* p = &items[0])
|
||||||
|
{
|
||||||
|
list.items = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Nca TryCreateNca(IStorage ncaStorage, string containerPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Nca(_virtualFileSystem.KeySet, ncaStorage);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "get_current_fps")]
|
[UnmanagedCallersOnly(EntryPoint = "get_current_fps")]
|
||||||
public static unsafe int GetFPS()
|
public static unsafe int GetFPS()
|
||||||
@ -751,7 +812,8 @@ namespace Ryujinx.Headless.SDL2
|
|||||||
|
|
||||||
if (File.Exists(titleUpdateMetadataPath))
|
if (File.Exists(titleUpdateMetadataPath))
|
||||||
{
|
{
|
||||||
updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
|
string updatePathRelative = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
|
||||||
|
updatePath = Path.Combine(AppDataManager.BaseDirPath, updatePathRelative);
|
||||||
|
|
||||||
if (File.Exists(updatePath))
|
if (File.Exists(updatePath))
|
||||||
{
|
{
|
||||||
@ -1517,6 +1579,19 @@ namespace Ryujinx.Headless.SDL2
|
|||||||
public byte[]? Icon;
|
public byte[]? Icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public unsafe struct DlcNcaListItem
|
||||||
|
{
|
||||||
|
public fixed byte Path[256];
|
||||||
|
public ulong TitleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe struct DlcNcaList
|
||||||
|
{
|
||||||
|
public bool success;
|
||||||
|
public uint size;
|
||||||
|
public unsafe DlcNcaListItem* items;
|
||||||
|
}
|
||||||
|
|
||||||
public unsafe struct GameInfoNative
|
public unsafe struct GameInfoNative
|
||||||
{
|
{
|
||||||
public ulong FileSize;
|
public ulong FileSize;
|
||||||
@ -1564,14 +1639,13 @@ namespace Ryujinx.Headless.SDL2
|
|||||||
ImageData = null;
|
ImageData = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void CopyStringToFixedArray(string source, byte* destination, int length)
|
private static unsafe void CopyStringToFixedArray(string source, byte* destination, int length)
|
||||||
{
|
{
|
||||||
var span = new Span<byte>(destination, length);
|
var span = new Span<byte>(destination, length);
|
||||||
span.Clear();
|
span.Clear();
|
||||||
Encoding.UTF8.GetBytes(source, span);
|
Encoding.UTF8.GetBytes(source, span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user