/ src / Ryujinx.HLE / HOS / ModLoader.cs
ModLoader.cs
  1  using LibHac.Common;
  2  using LibHac.Fs;
  3  using LibHac.Fs.Fsa;
  4  using LibHac.FsSystem;
  5  using LibHac.Loader;
  6  using LibHac.Tools.FsSystem;
  7  using LibHac.Tools.FsSystem.RomFs;
  8  using Ryujinx.Common.Configuration;
  9  using Ryujinx.Common.Logging;
 10  using Ryujinx.Common.Utilities;
 11  using Ryujinx.HLE.HOS.Kernel.Process;
 12  using Ryujinx.HLE.Loaders.Executables;
 13  using Ryujinx.HLE.Loaders.Mods;
 14  using Ryujinx.HLE.Loaders.Processes;
 15  using System;
 16  using System.Collections.Generic;
 17  using System.Collections.Specialized;
 18  using System.Globalization;
 19  using System.IO;
 20  using System.Linq;
 21  using LazyFile = Ryujinx.HLE.HOS.Services.Fs.FileSystemProxy.LazyFile;
 22  using Path = System.IO.Path;
 23  
 24  namespace Ryujinx.HLE.HOS
 25  {
 26      public class ModLoader
 27      {
 28          private const string RomfsDir = "romfs";
 29          private const string ExefsDir = "exefs";
 30          private const string CheatDir = "cheats";
 31          private const string RomfsContainer = "romfs.bin";
 32          private const string ExefsContainer = "exefs.nsp";
 33          private const string StubExtension = ".stub";
 34          private const string CheatExtension = ".txt";
 35          private const string DefaultCheatName = "<default>";
 36  
 37          private const string AmsContentsDir = "contents";
 38          private const string AmsNsoPatchDir = "exefs_patches";
 39          private const string AmsNroPatchDir = "nro_patches";
 40          private const string AmsKipPatchDir = "kip_patches";
 41  
 42          private static readonly ModMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
 43  
 44          public readonly struct Mod<T> where T : FileSystemInfo
 45          {
 46              public readonly string Name;
 47              public readonly T Path;
 48              public readonly bool Enabled;
 49  
 50              public Mod(string name, T path, bool enabled)
 51              {
 52                  Name = name;
 53                  Path = path;
 54                  Enabled = enabled;
 55              }
 56          }
 57  
 58          public struct Cheat
 59          {
 60              // Atmosphere identifies the executables with the first 8 bytes
 61              // of the build id, which is equivalent to 16 hex digits.
 62              public const int CheatIdSize = 16;
 63  
 64              public readonly string Name;
 65              public readonly FileInfo Path;
 66              public readonly IEnumerable<String> Instructions;
 67  
 68              public Cheat(string name, FileInfo path, IEnumerable<String> instructions)
 69              {
 70                  Name = name;
 71                  Path = path;
 72                  Instructions = instructions;
 73              }
 74          }
 75  
 76          // Application dependent mods
 77          public class ModCache
 78          {
 79              public List<Mod<FileInfo>> RomfsContainers { get; }
 80              public List<Mod<FileInfo>> ExefsContainers { get; }
 81  
 82              public List<Mod<DirectoryInfo>> RomfsDirs { get; }
 83              public List<Mod<DirectoryInfo>> ExefsDirs { get; }
 84  
 85              public List<Cheat> Cheats { get; }
 86  
 87              public ModCache()
 88              {
 89                  RomfsContainers = new List<Mod<FileInfo>>();
 90                  ExefsContainers = new List<Mod<FileInfo>>();
 91                  RomfsDirs = new List<Mod<DirectoryInfo>>();
 92                  ExefsDirs = new List<Mod<DirectoryInfo>>();
 93                  Cheats = new List<Cheat>();
 94              }
 95          }
 96  
 97          // Application independent mods
 98          private class PatchCache
 99          {
100              public List<Mod<DirectoryInfo>> NsoPatches { get; }
101              public List<Mod<DirectoryInfo>> NroPatches { get; }
102              public List<Mod<DirectoryInfo>> KipPatches { get; }
103  
104              internal bool Initialized { get; set; }
105  
106              public PatchCache()
107              {
108                  NsoPatches = new List<Mod<DirectoryInfo>>();
109                  NroPatches = new List<Mod<DirectoryInfo>>();
110                  KipPatches = new List<Mod<DirectoryInfo>>();
111  
112                  Initialized = false;
113              }
114          }
115  
116          private readonly Dictionary<ulong, ModCache> _appMods; // key is ApplicationId
117          private PatchCache _patches;
118  
119          private static readonly EnumerationOptions _dirEnumOptions;
120  
121          static ModLoader()
122          {
123              _dirEnumOptions = new EnumerationOptions
124              {
125                  MatchCasing = MatchCasing.CaseInsensitive,
126                  MatchType = MatchType.Simple,
127                  RecurseSubdirectories = false,
128                  ReturnSpecialDirectories = false,
129              };
130          }
131  
132          public ModLoader()
133          {
134              _appMods = new Dictionary<ulong, ModCache>();
135              _patches = new PatchCache();
136          }
137  
138          private void Clear()
139          {
140              _appMods.Clear();
141              _patches = new PatchCache();
142          }
143  
144          private static bool StrEquals(string s1, string s2) => string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase);
145  
146          public static string GetModsBasePath() => EnsureBaseDirStructure(AppDataManager.GetModsPath());
147          public static string GetSdModsBasePath() => EnsureBaseDirStructure(AppDataManager.GetSdModsPath());
148  
149          private static string EnsureBaseDirStructure(string modsBasePath)
150          {
151              var modsDir = new DirectoryInfo(modsBasePath);
152  
153              modsDir.CreateSubdirectory(AmsContentsDir);
154              modsDir.CreateSubdirectory(AmsNsoPatchDir);
155              modsDir.CreateSubdirectory(AmsNroPatchDir);
156              // TODO: uncomment when KIPs are supported
157              // modsDir.CreateSubdirectory(AmsKipPatchDir);
158  
159              return modsDir.FullName;
160          }
161  
162          private static DirectoryInfo FindApplicationDir(DirectoryInfo contentsDir, string applicationId)
163              => contentsDir.EnumerateDirectories(applicationId, _dirEnumOptions).FirstOrDefault();
164  
165          private static void AddModsFromDirectory(ModCache mods, DirectoryInfo dir, ModMetadata modMetadata)
166          {
167              System.Text.StringBuilder types = new();
168  
169              foreach (var modDir in dir.EnumerateDirectories())
170              {
171                  types.Clear();
172                  Mod<DirectoryInfo> mod = new("", null, true);
173  
174                  if (StrEquals(RomfsDir, modDir.Name))
175                  {
176                      var modData = modMetadata.Mods.Find(x => modDir.FullName.Contains(x.Path));
177                      var enabled = modData?.Enabled ?? true;
178  
179                      mods.RomfsDirs.Add(mod = new Mod<DirectoryInfo>(dir.Name, modDir, enabled));
180                      types.Append('R');
181                  }
182                  else if (StrEquals(ExefsDir, modDir.Name))
183                  {
184                      var modData = modMetadata.Mods.Find(x => modDir.FullName.Contains(x.Path));
185                      var enabled = modData?.Enabled ?? true;
186  
187                      mods.ExefsDirs.Add(mod = new Mod<DirectoryInfo>(dir.Name, modDir, enabled));
188                      types.Append('E');
189                  }
190                  else if (StrEquals(CheatDir, modDir.Name))
191                  {
192                      types.Append('C', QueryCheatsDir(mods, modDir));
193                  }
194                  else
195                  {
196                      AddModsFromDirectory(mods, modDir, modMetadata);
197                  }
198  
199                  if (types.Length > 0)
200                  {
201                      Logger.Info?.Print(LogClass.ModLoader, $"Found {(mod.Enabled ? "enabled" : "disabled")} mod '{mod.Name}' [{types}]");
202                  }
203              }
204          }
205  
206          public static string GetApplicationDir(string modsBasePath, string applicationId)
207          {
208              var contentsDir = new DirectoryInfo(Path.Combine(modsBasePath, AmsContentsDir));
209              var applicationModsPath = FindApplicationDir(contentsDir, applicationId);
210  
211              if (applicationModsPath == null)
212              {
213                  Logger.Info?.Print(LogClass.ModLoader, $"Creating mods directory for Application {applicationId.ToUpper()}");
214                  applicationModsPath = contentsDir.CreateSubdirectory(applicationId);
215              }
216  
217              return applicationModsPath.FullName;
218          }
219  
220          // Static Query Methods
221          private static void QueryPatchDirs(PatchCache cache, DirectoryInfo patchDir)
222          {
223              if (cache.Initialized || !patchDir.Exists)
224              {
225                  return;
226              }
227  
228              List<Mod<DirectoryInfo>> patches;
229              string type;
230  
231              if (StrEquals(AmsNsoPatchDir, patchDir.Name))
232              {
233                  patches = cache.NsoPatches;
234                  type = "NSO";
235              }
236              else if (StrEquals(AmsNroPatchDir, patchDir.Name))
237              {
238                  patches = cache.NroPatches;
239                  type = "NRO";
240              }
241              else if (StrEquals(AmsKipPatchDir, patchDir.Name))
242              {
243                  patches = cache.KipPatches;
244                  type = "KIP";
245              }
246              else
247              {
248                  return;
249              }
250  
251              foreach (var modDir in patchDir.EnumerateDirectories())
252              {
253                  patches.Add(new Mod<DirectoryInfo>(modDir.Name, modDir, true));
254                  Logger.Info?.Print(LogClass.ModLoader, $"Found {type} patch '{modDir.Name}'");
255              }
256          }
257  
258          private static void QueryApplicationDir(ModCache mods, DirectoryInfo applicationDir, ulong applicationId)
259          {
260              if (!applicationDir.Exists)
261              {
262                  return;
263              }
264  
265              string modJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationId.ToString("x16"), "mods.json");
266              ModMetadata modMetadata = new();
267  
268              if (File.Exists(modJsonPath))
269              {
270                  try
271                  {
272                      modMetadata = JsonHelper.DeserializeFromFile(modJsonPath, _serializerContext.ModMetadata);
273                  }
274                  catch
275                  {
276                      Logger.Warning?.Print(LogClass.ModLoader, $"Failed to deserialize mod data for {applicationId:X16} at {modJsonPath}");
277                  }
278              }
279  
280              var fsFile = new FileInfo(Path.Combine(applicationDir.FullName, RomfsContainer));
281              if (fsFile.Exists)
282              {
283                  var modData = modMetadata.Mods.Find(x => fsFile.FullName.Contains(x.Path));
284                  var enabled = modData == null || modData.Enabled;
285  
286                  mods.RomfsContainers.Add(new Mod<FileInfo>($"<{applicationDir.Name} RomFs>", fsFile, enabled));
287              }
288  
289              fsFile = new FileInfo(Path.Combine(applicationDir.FullName, ExefsContainer));
290              if (fsFile.Exists)
291              {
292                  var modData = modMetadata.Mods.Find(x => fsFile.FullName.Contains(x.Path));
293                  var enabled = modData == null || modData.Enabled;
294  
295                  mods.ExefsContainers.Add(new Mod<FileInfo>($"<{applicationDir.Name} ExeFs>", fsFile, enabled));
296              }
297  
298              AddModsFromDirectory(mods, applicationDir, modMetadata);
299          }
300  
301          public static void QueryContentsDir(ModCache mods, DirectoryInfo contentsDir, ulong applicationId)
302          {
303              if (!contentsDir.Exists)
304              {
305                  return;
306              }
307  
308              Logger.Info?.Print(LogClass.ModLoader, $"Searching mods for {((applicationId & 0x1000) != 0 ? "DLC" : "Application")} {applicationId:X16} in \"{contentsDir.FullName}\"");
309  
310              var applicationDir = FindApplicationDir(contentsDir, $"{applicationId:x16}");
311  
312              if (applicationDir != null)
313              {
314                  QueryApplicationDir(mods, applicationDir, applicationId);
315              }
316          }
317  
318          private static int QueryCheatsDir(ModCache mods, DirectoryInfo cheatsDir)
319          {
320              if (!cheatsDir.Exists)
321              {
322                  return 0;
323              }
324  
325              int numMods = 0;
326  
327              foreach (FileInfo file in cheatsDir.EnumerateFiles())
328              {
329                  if (!StrEquals(CheatExtension, file.Extension))
330                  {
331                      continue;
332                  }
333  
334                  string cheatId = Path.GetFileNameWithoutExtension(file.Name);
335  
336                  if (cheatId.Length != Cheat.CheatIdSize)
337                  {
338                      continue;
339                  }
340  
341                  if (!ulong.TryParse(cheatId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
342                  {
343                      continue;
344                  }
345  
346                  int oldCheatsCount = mods.Cheats.Count;
347  
348                  // A cheat file can contain several cheats for the same executable, so the file must be parsed in
349                  // order to properly enumerate them.
350                  mods.Cheats.AddRange(GetCheatsInFile(file));
351  
352                  if (mods.Cheats.Count - oldCheatsCount > 0)
353                  {
354                      numMods++;
355                  }
356              }
357  
358              return numMods;
359          }
360  
361          private static IEnumerable<Cheat> GetCheatsInFile(FileInfo cheatFile)
362          {
363              string cheatName = DefaultCheatName;
364              List<string> instructions = new();
365              List<Cheat> cheats = new();
366  
367              using StreamReader cheatData = cheatFile.OpenText();
368              while (cheatData.ReadLine() is { } line)
369              {
370                  line = line.Trim();
371  
372                  if (line.StartsWith('['))
373                  {
374                      // This line starts a new cheat section.
375                      if (!line.EndsWith(']') || line.Length < 3)
376                      {
377                          // Skip the entire file if there's any error while parsing the cheat file.
378  
379                          Logger.Warning?.Print(LogClass.ModLoader, $"Ignoring cheat '{cheatFile.FullName}' because it is malformed");
380  
381                          return Array.Empty<Cheat>();
382                      }
383  
384                      // Add the previous section to the list.
385                      if (instructions.Count > 0)
386                      {
387                          cheats.Add(new Cheat($"<{cheatName} Cheat>", cheatFile, instructions));
388                      }
389  
390                      // Start a new cheat section.
391                      cheatName = line[1..^1];
392                      instructions = new List<string>();
393                  }
394                  else if (line.Length > 0)
395                  {
396                      // The line contains an instruction.
397                      instructions.Add(line);
398                  }
399              }
400  
401              // Add the last section being processed.
402              if (instructions.Count > 0)
403              {
404                  cheats.Add(new Cheat($"<{cheatName} Cheat>", cheatFile, instructions));
405              }
406  
407              return cheats;
408          }
409  
410          // Assumes searchDirPaths don't overlap
411          private static void CollectMods(Dictionary<ulong, ModCache> modCaches, PatchCache patches, params string[] searchDirPaths)
412          {
413              static bool IsPatchesDir(string name) => StrEquals(AmsNsoPatchDir, name) ||
414                                                       StrEquals(AmsNroPatchDir, name) ||
415                                                       StrEquals(AmsKipPatchDir, name);
416  
417              static bool IsContentsDir(string name) => StrEquals(AmsContentsDir, name);
418  
419              static bool TryQuery(DirectoryInfo searchDir, PatchCache patches, Dictionary<ulong, ModCache> modCaches)
420              {
421                  if (IsContentsDir(searchDir.Name))
422                  {
423                      foreach ((ulong applicationId, ModCache cache) in modCaches)
424                      {
425                          QueryContentsDir(cache, searchDir, applicationId);
426                      }
427  
428                      return true;
429                  }
430                  else if (IsPatchesDir(searchDir.Name))
431                  {
432                      QueryPatchDirs(patches, searchDir);
433  
434                      return true;
435                  }
436  
437                  return false;
438              }
439  
440              foreach (var path in searchDirPaths)
441              {
442                  var searchDir = new DirectoryInfo(path);
443                  if (!searchDir.Exists)
444                  {
445                      Logger.Warning?.Print(LogClass.ModLoader, $"Mod Search Dir '{searchDir.FullName}' doesn't exist");
446                      return;
447                  }
448  
449                  if (!TryQuery(searchDir, patches, modCaches))
450                  {
451                      foreach (var subdir in searchDir.EnumerateDirectories())
452                      {
453                          TryQuery(subdir, patches, modCaches);
454                      }
455                  }
456              }
457  
458              patches.Initialized = true;
459          }
460  
461          public void CollectMods(IEnumerable<ulong> applications, params string[] searchDirPaths)
462          {
463              Clear();
464  
465              foreach (ulong applicationId in applications)
466              {
467                  _appMods[applicationId] = new ModCache();
468              }
469  
470              CollectMods(_appMods, _patches, searchDirPaths);
471          }
472  
473          internal IStorage ApplyRomFsMods(ulong applicationId, IStorage baseStorage)
474          {
475              if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.RomfsDirs.Count + mods.RomfsContainers.Count == 0)
476              {
477                  return baseStorage;
478              }
479  
480              var fileSet = new HashSet<string>();
481              var builder = new RomFsBuilder();
482              int count = 0;
483  
484              Logger.Info?.Print(LogClass.ModLoader, $"Applying RomFS mods for Application {applicationId:X16}");
485  
486              // Prioritize loose files first
487              foreach (var mod in mods.RomfsDirs)
488              {
489                  if (!mod.Enabled)
490                  {
491                      continue;
492                  }
493  
494                  using (IFileSystem fs = new LocalFileSystem(mod.Path.FullName))
495                  {
496                      AddFiles(fs, mod.Name, mod.Path.FullName, fileSet, builder);
497                  }
498                  count++;
499              }
500  
501              // Then files inside images
502              foreach (var mod in mods.RomfsContainers)
503              {
504                  if (!mod.Enabled)
505                  {
506                      continue;
507                  }
508  
509                  Logger.Info?.Print(LogClass.ModLoader, $"Found 'romfs.bin' for Application {applicationId:X16}");
510                  using (IFileSystem fs = new RomFsFileSystem(mod.Path.OpenRead().AsStorage()))
511                  {
512                      AddFiles(fs, mod.Name, mod.Path.FullName, fileSet, builder);
513                  }
514                  count++;
515              }
516  
517              if (fileSet.Count == 0)
518              {
519                  Logger.Info?.Print(LogClass.ModLoader, "No files found. Using base RomFS");
520  
521                  return baseStorage;
522              }
523  
524              Logger.Info?.Print(LogClass.ModLoader, $"Replaced {fileSet.Count} file(s) over {count} mod(s). Processing base storage...");
525  
526              // And finally, the base romfs
527              var baseRom = new RomFsFileSystem(baseStorage);
528              foreach (var entry in baseRom.EnumerateEntries()
529                                           .Where(f => f.Type == DirectoryEntryType.File && !fileSet.Contains(f.FullPath))
530                                           .OrderBy(f => f.FullPath, StringComparer.Ordinal))
531              {
532                  using var file = new UniqueRef<IFile>();
533  
534                  baseRom.OpenFile(ref file.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
535                  builder.AddFile(entry.FullPath, file.Release());
536              }
537  
538              Logger.Info?.Print(LogClass.ModLoader, "Building new RomFS...");
539              IStorage newStorage = builder.Build();
540              Logger.Info?.Print(LogClass.ModLoader, "Using modded RomFS");
541  
542              return newStorage;
543          }
544  
545          private static void AddFiles(IFileSystem fs, string modName, string rootPath, ISet<string> fileSet, RomFsBuilder builder)
546          {
547              foreach (var entry in fs.EnumerateEntries()
548                                      .AsParallel()
549                                      .Where(f => f.Type == DirectoryEntryType.File)
550                                      .OrderBy(f => f.FullPath, StringComparer.Ordinal))
551              {
552                  var file = new LazyFile(entry.FullPath, rootPath, fs);
553  
554                  if (fileSet.Add(entry.FullPath))
555                  {
556                      builder.AddFile(entry.FullPath, file);
557                  }
558                  else
559                  {
560                      Logger.Warning?.Print(LogClass.ModLoader, $"    Skipped duplicate file '{entry.FullPath}' from '{modName}'", "ApplyRomFsMods");
561                  }
562              }
563          }
564  
565          internal bool ReplaceExefsPartition(ulong applicationId, ref IFileSystem exefs)
566          {
567              if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.ExefsContainers.Count == 0)
568              {
569                  return false;
570              }
571  
572              if (mods.ExefsContainers.Count > 1)
573              {
574                  Logger.Warning?.Print(LogClass.ModLoader, "Multiple ExeFS partition replacements detected");
575              }
576  
577              Logger.Info?.Print(LogClass.ModLoader, "Using replacement ExeFS partition");
578  
579              var pfs = new PartitionFileSystem();
580              pfs.Initialize(mods.ExefsContainers[0].Path.OpenRead().AsStorage()).ThrowIfFailure();
581              exefs = pfs;
582  
583              return true;
584          }
585  
586          public struct ModLoadResult
587          {
588              public BitVector32 Stubs;
589              public BitVector32 Replaces;
590              public MetaLoader Npdm;
591  
592              public bool Modified => (Stubs.Data | Replaces.Data) != 0;
593          }
594  
595          internal ModLoadResult ApplyExefsMods(ulong applicationId, NsoExecutable[] nsos)
596          {
597              ModLoadResult modLoadResult = new()
598              {
599                  Stubs = new BitVector32(),
600                  Replaces = new BitVector32(),
601              };
602  
603              if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.ExefsDirs.Count == 0)
604              {
605                  return modLoadResult;
606              }
607  
608              if (nsos.Length != ProcessConst.ExeFsPrefixes.Length)
609              {
610                  throw new ArgumentOutOfRangeException(nameof(nsos), nsos.Length, "NSO Count is incorrect");
611              }
612  
613              var exeMods = mods.ExefsDirs;
614  
615              foreach (var mod in exeMods)
616              {
617                  if (!mod.Enabled)
618                  {
619                      continue;
620                  }
621  
622                  for (int i = 0; i < ProcessConst.ExeFsPrefixes.Length; ++i)
623                  {
624                      var nsoName = ProcessConst.ExeFsPrefixes[i];
625  
626                      FileInfo nsoFile = new(Path.Combine(mod.Path.FullName, nsoName));
627                      if (nsoFile.Exists)
628                      {
629                          if (modLoadResult.Replaces[1 << i])
630                          {
631                              Logger.Warning?.Print(LogClass.ModLoader, $"Multiple replacements to '{nsoName}'");
632  
633                              continue;
634                          }
635  
636                          modLoadResult.Replaces[1 << i] = true;
637  
638                          nsos[i] = new NsoExecutable(nsoFile.OpenRead().AsStorage(), nsoName);
639                          Logger.Info?.Print(LogClass.ModLoader, $"NSO '{nsoName}' replaced");
640                      }
641  
642                      modLoadResult.Stubs[1 << i] |= File.Exists(Path.Combine(mod.Path.FullName, nsoName + StubExtension));
643                  }
644  
645                  FileInfo npdmFile = new(Path.Combine(mod.Path.FullName, "main.npdm"));
646                  if (npdmFile.Exists)
647                  {
648                      if (modLoadResult.Npdm != null)
649                      {
650                          Logger.Warning?.Print(LogClass.ModLoader, "Multiple replacements to 'main.npdm'");
651  
652                          continue;
653                      }
654  
655                      modLoadResult.Npdm = new MetaLoader();
656                      modLoadResult.Npdm.Load(File.ReadAllBytes(npdmFile.FullName));
657  
658                      Logger.Info?.Print(LogClass.ModLoader, "main.npdm replaced");
659                  }
660              }
661  
662              for (int i = ProcessConst.ExeFsPrefixes.Length - 1; i >= 0; --i)
663              {
664                  if (modLoadResult.Stubs[1 << i] && !modLoadResult.Replaces[1 << i]) // Prioritizes replacements over stubs
665                  {
666                      Logger.Info?.Print(LogClass.ModLoader, $"    NSO '{nsos[i].Name}' stubbed");
667                      nsos[i] = null;
668                  }
669              }
670  
671              return modLoadResult;
672          }
673  
674          internal void ApplyNroPatches(NroExecutable nro)
675          {
676              var nroPatches = _patches.NroPatches;
677  
678              if (nroPatches.Count == 0)
679              {
680                  return;
681              }
682  
683              // NRO patches aren't offset relative to header unlike NSO
684              // according to Atmosphere's ro patcher module
685              ApplyProgramPatches(nroPatches, 0, nro);
686          }
687  
688          internal bool ApplyNsoPatches(ulong applicationId, params IExecutable[] programs)
689          {
690              IEnumerable<Mod<DirectoryInfo>> nsoMods = _patches.NsoPatches;
691  
692              if (_appMods.TryGetValue(applicationId, out ModCache mods))
693              {
694                  nsoMods = nsoMods.Concat(mods.ExefsDirs);
695              }
696  
697              // NSO patches are created with offset 0 according to Atmosphere's patcher module
698              // But `Program` doesn't contain the header which is 0x100 bytes. So, we adjust for that here
699              return ApplyProgramPatches(nsoMods, 0x100, programs);
700          }
701  
702          internal void LoadCheats(ulong applicationId, ProcessTamperInfo tamperInfo, TamperMachine tamperMachine)
703          {
704              if (tamperInfo?.BuildIds == null || tamperInfo.CodeAddresses == null)
705              {
706                  Logger.Error?.Print(LogClass.ModLoader, "Unable to install cheat because the associated process is invalid");
707  
708                  return;
709              }
710  
711              Logger.Info?.Print(LogClass.ModLoader, $"Build ids found for application {applicationId:X16}:\n    {String.Join("\n    ", tamperInfo.BuildIds)}");
712  
713              if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.Cheats.Count == 0)
714              {
715                  return;
716              }
717  
718              var cheats = mods.Cheats;
719              var processExes = tamperInfo.BuildIds.Zip(tamperInfo.CodeAddresses, (k, v) => new { k, v })
720                  .ToDictionary(x => x.k[..Math.Min(Cheat.CheatIdSize, x.k.Length)], x => x.v);
721  
722              foreach (var cheat in cheats)
723              {
724                  string cheatId = Path.GetFileNameWithoutExtension(cheat.Path.Name).ToUpper();
725  
726                  if (!processExes.TryGetValue(cheatId, out ulong exeAddress))
727                  {
728                      Logger.Warning?.Print(LogClass.ModLoader, $"Skipping cheat '{cheat.Name}' because no executable matches its BuildId {cheatId} (check if the game title and version are correct)");
729  
730                      continue;
731                  }
732  
733                  Logger.Info?.Print(LogClass.ModLoader, $"Installing cheat '{cheat.Name}'");
734  
735                  tamperMachine.InstallAtmosphereCheat(cheat.Name, cheatId, cheat.Instructions, tamperInfo, exeAddress);
736              }
737  
738              EnableCheats(applicationId, tamperMachine);
739          }
740  
741          internal static void EnableCheats(ulong applicationId, TamperMachine tamperMachine)
742          {
743              var contentDirectory = FindApplicationDir(new DirectoryInfo(Path.Combine(GetModsBasePath(), AmsContentsDir)), $"{applicationId:x16}");
744              string enabledCheatsPath = Path.Combine(contentDirectory.FullName, CheatDir, "enabled.txt");
745  
746              if (File.Exists(enabledCheatsPath))
747              {
748                  tamperMachine.EnableCheats(File.ReadAllLines(enabledCheatsPath));
749              }
750          }
751  
752          private static bool ApplyProgramPatches(IEnumerable<Mod<DirectoryInfo>> mods, int protectedOffset, params IExecutable[] programs)
753          {
754              int count = 0;
755  
756              MemPatch[] patches = new MemPatch[programs.Length];
757  
758              for (int i = 0; i < patches.Length; ++i)
759              {
760                  patches[i] = new MemPatch();
761              }
762  
763              var buildIds = programs.Select(p => p switch
764              {
765                  NsoExecutable nso => Convert.ToHexString(nso.BuildId.ItemsRo.ToArray()).TrimEnd('0'),
766                  NroExecutable nro => Convert.ToHexString(nro.Header.BuildId).TrimEnd('0'),
767                  _ => string.Empty,
768              }).ToList();
769  
770              int GetIndex(string buildId) => buildIds.FindIndex(id => id == buildId); // O(n) but list is small
771  
772              // Collect patches
773              foreach (var mod in mods)
774              {
775                  if (!mod.Enabled)
776                  {
777                      continue;
778                  }
779  
780                  var patchDir = mod.Path;
781                  foreach (var patchFile in patchDir.EnumerateFiles())
782                  {
783                      if (StrEquals(".ips", patchFile.Extension)) // IPS|IPS32
784                      {
785                          string filename = Path.GetFileNameWithoutExtension(patchFile.FullName).Split('.')[0];
786                          string buildId = filename.TrimEnd('0');
787  
788                          int index = GetIndex(buildId);
789                          if (index == -1)
790                          {
791                              continue;
792                          }
793  
794                          Logger.Info?.Print(LogClass.ModLoader, $"Matching IPS patch '{patchFile.Name}' in '{mod.Name}' bid={buildId}");
795  
796                          using var fs = patchFile.OpenRead();
797                          using var reader = new BinaryReader(fs);
798  
799                          var patcher = new IpsPatcher(reader);
800                          patcher.AddPatches(patches[index]);
801                      }
802                      else if (StrEquals(".pchtxt", patchFile.Extension)) // IPSwitch
803                      {
804                          using var fs = patchFile.OpenRead();
805                          using var reader = new StreamReader(fs);
806  
807                          var patcher = new IPSwitchPatcher(reader);
808  
809                          int index = GetIndex(patcher.BuildId);
810                          if (index == -1)
811                          {
812                              continue;
813                          }
814  
815                          Logger.Info?.Print(LogClass.ModLoader, $"Matching IPSwitch patch '{patchFile.Name}' in '{mod.Name}' bid={patcher.BuildId}");
816  
817                          patcher.AddPatches(patches[index]);
818                      }
819                  }
820              }
821  
822              // Apply patches
823              for (int i = 0; i < programs.Length; ++i)
824              {
825                  count += patches[i].Patch(programs[i].Program, protectedOffset);
826              }
827  
828              return count > 0;
829          }
830      }
831  }