forked from MeloNX/MeloNX
DLC manager implemented
This commit is contained in:
parent
cce876c6f5
commit
ddf634ecb6
@ -31,8 +31,21 @@ struct GameInfo {
|
||||
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 DlcNcaList get_dlc_nca_list(const char* titleIdPtr, const char* pathPtr);
|
||||
|
||||
void install_firmware(const char* inputPtr);
|
||||
|
||||
char* installed_firmware_version();
|
||||
@ -43,8 +56,6 @@ int main_ryujinx_sdl(int argc, char **argv);
|
||||
|
||||
int get_current_fps();
|
||||
|
||||
void set_title_update(const char* titleIdPtr, const char* updatePathPtr);
|
||||
|
||||
void initialize();
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
@ -222,7 +222,7 @@ class Ryujinx {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return games
|
||||
} catch {
|
||||
print("Error loading games from roms folder: \(error)")
|
||||
@ -365,19 +365,30 @@ class Ryujinx {
|
||||
self.firmwareversion = version
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setTitleUpdate(titleId: String, updatePath: String) {
|
||||
guard let titleIdPtr = titleId.cString(using: .utf8),
|
||||
let updatePathPtr = updatePath.cString(using: .utf8)
|
||||
|
||||
func getDlcNcaList(titleId: String, path: String) -> [DownloadableContentNca] {
|
||||
guard let titleIdCString = titleId.cString(using: .utf8),
|
||||
let pathCString = path.cString(using: .utf8)
|
||||
else {
|
||||
print("Invalid firmware path")
|
||||
return
|
||||
print("Invalid path")
|
||||
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? {
|
||||
let guid = SDL_JoystickGetDeviceGUID(joystickIndex)
|
||||
|
||||
|
@ -28,6 +28,7 @@ struct GameLibraryView: View {
|
||||
@State var isSelectingGameFile = false
|
||||
@State var isViewingGameInfo: Bool = false
|
||||
@State var isSelectingGameUpdate: Bool = false
|
||||
@State var isSelectingGameDLC: Bool = false
|
||||
@State var gameInfo: Game?
|
||||
var games: Binding<[Game]> {
|
||||
Binding(
|
||||
@ -92,7 +93,7 @@ struct GameLibraryView: View {
|
||||
|
||||
LazyVStack(spacing: 2) {
|
||||
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)
|
||||
}
|
||||
@ -101,7 +102,7 @@ struct GameLibraryView: View {
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
@ -271,6 +272,9 @@ struct GameLibraryView: View {
|
||||
.sheet(isPresented: $isSelectingGameUpdate) {
|
||||
UpdateManagerSheet(game: $gameInfo)
|
||||
}
|
||||
.sheet(isPresented: $isSelectingGameDLC) {
|
||||
DLCManagerSheet(game: $gameInfo)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { isViewingGameInfo && gameInfo != nil },
|
||||
set: { newValue in
|
||||
@ -414,6 +418,7 @@ struct GameListRow: View {
|
||||
@Binding var games: [Game] // Add this binding
|
||||
@Binding var isViewingGameInfo: Bool
|
||||
@Binding var isSelectingGameUpdate: Bool
|
||||
@Binding var isSelectingGameDLC: Bool
|
||||
@Binding var gameInfo: Game?
|
||||
@State var gametoDelete: Game?
|
||||
@State var showGameDeleteConfirmation: Bool = false
|
||||
@ -476,13 +481,22 @@ struct GameListRow: View {
|
||||
} label: {
|
||||
Label("Game Info", systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
gameInfo = game
|
||||
isSelectingGameUpdate.toggle()
|
||||
} label: {
|
||||
Label("Game Update Manager", systemImage: "chevron.up.circle")
|
||||
}
|
||||
|
||||
Button {
|
||||
gameInfo = game
|
||||
isSelectingGameDLC.toggle()
|
||||
} label: {
|
||||
Label("Game DLC Manager", systemImage: "plus.viewfinder")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
|
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)
|
||||
.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")
|
||||
}
|
||||
}
|
@ -142,34 +142,95 @@ namespace Ryujinx.Headless.SDL2
|
||||
return 0;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "set_title_update")]
|
||||
public static unsafe void SetTitleUpdate(IntPtr titleIdPtr, IntPtr updatePathPtr) {
|
||||
[UnmanagedCallersOnly(EntryPoint = "get_dlc_nca_list")]
|
||||
public static unsafe DlcNcaList GetDlcNcaList(IntPtr titleIdPtr, IntPtr pathPtr)
|
||||
{
|
||||
var titleId = Marshal.PtrToStringAnsi(titleIdPtr);
|
||||
var updatePath = Marshal.PtrToStringAnsi(updatePathPtr);
|
||||
string _updateJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleId, "updates.json");
|
||||
var containerPath = Marshal.PtrToStringAnsi(pathPtr);
|
||||
|
||||
TitleUpdateMetadata _titleUpdateWindowData;
|
||||
|
||||
if (File.Exists(_updateJsonPath)) {
|
||||
_titleUpdateWindowData = JsonHelper.DeserializeFromFile<TitleUpdateMetadata>(_updateJsonPath, _titleSerializerContext.TitleUpdateMetadata);
|
||||
|
||||
_titleUpdateWindowData.Paths ??= new List<string>();
|
||||
if (!_titleUpdateWindowData.Paths.Contains(updatePath)) {
|
||||
_titleUpdateWindowData.Paths.Add(updatePath);
|
||||
}
|
||||
|
||||
_titleUpdateWindowData.Selected = updatePath;
|
||||
} else {
|
||||
_titleUpdateWindowData = new TitleUpdateMetadata {
|
||||
Selected = updatePath,
|
||||
Paths = new List<string> { updatePath },
|
||||
};
|
||||
if (!File.Exists(containerPath))
|
||||
{
|
||||
return new DlcNcaList { success = false };
|
||||
}
|
||||
|
||||
JsonHelper.SerializeToFile(_updateJsonPath, _titleUpdateWindowData, _titleSerializerContext.TitleUpdateMetadata);
|
||||
using FileStream containerFile = File.OpenRead(containerPath);
|
||||
|
||||
PartitionFileSystem pfs = new();
|
||||
pfs.Initialize(containerFile.AsStorage()).ThrowIfFailure();
|
||||
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;
|
||||
}
|
||||
|
||||
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")]
|
||||
public static unsafe int GetFPS()
|
||||
@ -1518,6 +1579,19 @@ namespace Ryujinx.Headless.SDL2
|
||||
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 ulong FileSize;
|
||||
@ -1565,14 +1639,13 @@ namespace Ryujinx.Headless.SDL2
|
||||
ImageData = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyStringToFixedArray(string source, byte* destination, int length)
|
||||
{
|
||||
var span = new Span<byte>(destination, length);
|
||||
span.Clear();
|
||||
Encoding.UTF8.GetBytes(source, span);
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void CopyStringToFixedArray(string source, byte* destination, int length)
|
||||
{
|
||||
var span = new Span<byte>(destination, length);
|
||||
span.Clear();
|
||||
Encoding.UTF8.GetBytes(source, span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user