From ddf634ecb64da224f2d70226d96c5af8b3588709 Mon Sep 17 00:00:00 2001
From: Daniil Vinogradov <xitrix@bk.ru>
Date: Mon, 17 Feb 2025 00:27:42 +0100
Subject: [PATCH] DLC manager implemented

---
 .../MeloNX/App/Core/Headers/Ryujinx-Header.h  |  15 +-
 .../MeloNX/App/Core/Ryujinx/Ryujinx.swift     |  31 ++--
 .../App/Views/GamesList/GameListView.swift    |  18 +-
 .../Views/Updates/GameDLCManagerSheet.swift   | 159 ++++++++++++++++++
 src/Ryujinx.Headless.SDL2/Program.cs          | 131 +++++++++++----
 5 files changed, 311 insertions(+), 43 deletions(-)
 create mode 100644 src/MeloNX/MeloNX/App/Views/Updates/GameDLCManagerSheet.swift

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<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);
+        }
     }
 }