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 }