diff --git a/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h b/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h index b7e8191b4..10a6e7b54 100644 --- a/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h +++ b/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h @@ -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 diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift index 779ece9d1..f41d2bdb0 100644 --- a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift @@ -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) diff --git a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift index 96a1e51fb..320556ab9 100644 --- a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift +++ b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift @@ -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 { diff --git a/src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift b/src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift new file mode 100644 index 000000000..817a8be81 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift @@ -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") + } +} diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs index 5c6b84c11..f8756b313 100644 --- a/src/Ryujinx.Headless.SDL2/Program.cs +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -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(_updateJsonPath, _titleSerializerContext.TitleUpdateMetadata); - - _titleUpdateWindowData.Paths ??= new List(); - if (!_titleUpdateWindowData.Paths.Contains(updatePath)) { - _titleUpdateWindowData.Paths.Add(updatePath); - } - - _titleUpdateWindowData.Selected = updatePath; - } else { - _titleUpdateWindowData = new TitleUpdateMetadata { - Selected = updatePath, - Paths = new List { 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 listItems = new(); + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) + { + using var ncaFile = new UniqueRef(); + + 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(destination, length); - span.Clear(); - Encoding.UTF8.GetBytes(source, span); - } } + private static unsafe void CopyStringToFixedArray(string source, byte* destination, int length) + { + var span = new Span(destination, length); + span.Clear(); + Encoding.UTF8.GetBytes(source, span); + } } }