mirror of
https://github.com/Ryujinx/Ryujinx.git
synced 2024-08-18 06:38:42 +02:00
Implement modding support (#1249)
* Implement Modding Support * Executables: Rewrite to use contiguous mem and Spans * Reorder ExeFs, Npdm, ControlData and SaveData calls After discussion with gdkchan, it was decided it's best to call LoadExeFs after all other loads are done as it starts the guest process. * Build RomFs manually instead of Layering FS Layered FS approach has considerable latency when building the final romfs. So, we manually replace files in a single romfs instance. * Add RomFs modding via storage file * Fix and cleanup MemPatch * Add dynamically loaded NRO patching * Support exefs file replacement * Rewrite ModLoader to use mods-search architecture * Disable PPTC when exefs patches are detected Disable PPTC on exefs replacements too * Rewrite ModLoader, again * Increased maintainability and matches Atmosphere closely * Creates base mods structure if it doesn't exist * Add Exefs partition replacement * IPSwitch: Fix nsobid parsing * Move mod logs to new LogClass * Allow custom suffixes to title dirs again * Address nits * Add a per-App "Open Mods Directory" context menu item Creates the path if not present. * Normalize tooltips verbiage * Use LocalStorage and remove unused namespaces
This commit is contained in:
parent
c050994995
commit
189c0c9c72
18 changed files with 1234 additions and 153 deletions
|
@ -14,6 +14,7 @@ namespace Ryujinx.Common.Logging
|
||||||
KernelScheduler,
|
KernelScheduler,
|
||||||
KernelSvc,
|
KernelSvc,
|
||||||
Loader,
|
Loader,
|
||||||
|
ModLoader,
|
||||||
Ptc,
|
Ptc,
|
||||||
Service,
|
Service,
|
||||||
ServiceAcc,
|
ServiceAcc,
|
||||||
|
|
|
@ -17,6 +17,7 @@ namespace Ryujinx.HLE.FileSystem
|
||||||
public const string NandPath = "bis";
|
public const string NandPath = "bis";
|
||||||
public const string SdCardPath = "sdcard";
|
public const string SdCardPath = "sdcard";
|
||||||
public const string SystemPath = "system";
|
public const string SystemPath = "system";
|
||||||
|
public const string ModsPath = "mods";
|
||||||
|
|
||||||
public static string SafeNandPath = Path.Combine(NandPath, "safe");
|
public static string SafeNandPath = Path.Combine(NandPath, "safe");
|
||||||
public static string SystemNandPath = Path.Combine(NandPath, "system");
|
public static string SystemNandPath = Path.Combine(NandPath, "system");
|
||||||
|
@ -30,9 +31,12 @@ namespace Ryujinx.HLE.FileSystem
|
||||||
public EmulatedGameCard GameCard { get; private set; }
|
public EmulatedGameCard GameCard { get; private set; }
|
||||||
public EmulatedSdCard SdCard { get; private set; }
|
public EmulatedSdCard SdCard { get; private set; }
|
||||||
|
|
||||||
|
public ModLoader ModLoader {get; private set;}
|
||||||
|
|
||||||
private VirtualFileSystem()
|
private VirtualFileSystem()
|
||||||
{
|
{
|
||||||
Reload();
|
Reload();
|
||||||
|
ModLoader = new ModLoader(); // Should only be created once
|
||||||
}
|
}
|
||||||
|
|
||||||
public Stream RomFs { get; private set; }
|
public Stream RomFs { get; private set; }
|
||||||
|
@ -73,6 +77,14 @@ namespace Ryujinx.HLE.FileSystem
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetBaseModsPath()
|
||||||
|
{
|
||||||
|
var baseModsDir = Path.Combine(GetBasePath(), "mods");
|
||||||
|
ModLoader.EnsureBaseDirStructure(baseModsDir);
|
||||||
|
|
||||||
|
return baseModsDir;
|
||||||
|
}
|
||||||
|
|
||||||
public string GetSdCardPath() => MakeFullPath(SdCardPath);
|
public string GetSdCardPath() => MakeFullPath(SdCardPath);
|
||||||
|
|
||||||
public string GetNandPath() => MakeFullPath(NandPath);
|
public string GetNandPath() => MakeFullPath(NandPath);
|
||||||
|
|
|
@ -43,7 +43,8 @@ namespace Ryujinx.HLE.HOS
|
||||||
|
|
||||||
public bool EnablePtc => _device.System.EnablePtc;
|
public bool EnablePtc => _device.System.EnablePtc;
|
||||||
|
|
||||||
public IntegrityCheckLevel FsIntegrityCheckLevel => _device.System.FsIntegrityCheckLevel;
|
// Binaries from exefs are loaded into mem in this order. Do not change.
|
||||||
|
private static readonly string[] ExeFsPrefixes = { "rtld", "main", "subsdk*", "sdk" };
|
||||||
|
|
||||||
public ApplicationLoader(Switch device, VirtualFileSystem fileSystem, ContentManager contentManager)
|
public ApplicationLoader(Switch device, VirtualFileSystem fileSystem, ContentManager contentManager)
|
||||||
{
|
{
|
||||||
|
@ -52,6 +53,9 @@ namespace Ryujinx.HLE.HOS
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
|
|
||||||
ControlData = new BlitStruct<ApplicationControlProperty>(1);
|
ControlData = new BlitStruct<ApplicationControlProperty>(1);
|
||||||
|
|
||||||
|
// Clear Mods cache
|
||||||
|
_fileSystem.ModLoader.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadCart(string exeFsDir, string romFsFile = null)
|
public void LoadCart(string exeFsDir, string romFsFile = null)
|
||||||
|
@ -63,12 +67,14 @@ namespace Ryujinx.HLE.HOS
|
||||||
|
|
||||||
LocalFileSystem codeFs = new LocalFileSystem(exeFsDir);
|
LocalFileSystem codeFs = new LocalFileSystem(exeFsDir);
|
||||||
|
|
||||||
LoadExeFs(codeFs, out _);
|
Npdm metaData = ReadNpdm(codeFs);
|
||||||
|
|
||||||
if (TitleId != 0)
|
if (TitleId != 0)
|
||||||
{
|
{
|
||||||
EnsureSaveData(new TitleId(TitleId));
|
EnsureSaveData(new TitleId(TitleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoadExeFs(codeFs, metaData);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Nca main, Nca patch, Nca control) GetGameData(PartitionFileSystem pfs)
|
private (Nca main, Nca patch, Nca control) GetGameData(PartitionFileSystem pfs)
|
||||||
|
@ -191,7 +197,7 @@ namespace Ryujinx.HLE.HOS
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is not a normal NSP, it's actually a ExeFS as a NSP
|
// This is not a normal NSP, it's actually a ExeFS as a NSP
|
||||||
LoadExeFs(nsp, out _);
|
LoadExeFs(nsp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadNca(string ncaFile)
|
public void LoadNca(string ncaFile)
|
||||||
|
@ -272,24 +278,24 @@ namespace Ryujinx.HLE.HOS
|
||||||
{
|
{
|
||||||
if (mainNca.CanOpenSection(NcaSectionType.Data))
|
if (mainNca.CanOpenSection(NcaSectionType.Data))
|
||||||
{
|
{
|
||||||
dataStorage = mainNca.OpenStorage(NcaSectionType.Data, FsIntegrityCheckLevel);
|
dataStorage = mainNca.OpenStorage(NcaSectionType.Data, _device.System.FsIntegrityCheckLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainNca.CanOpenSection(NcaSectionType.Code))
|
if (mainNca.CanOpenSection(NcaSectionType.Code))
|
||||||
{
|
{
|
||||||
codeFs = mainNca.OpenFileSystem(NcaSectionType.Code, FsIntegrityCheckLevel);
|
codeFs = mainNca.OpenFileSystem(NcaSectionType.Code, _device.System.FsIntegrityCheckLevel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (patchNca.CanOpenSection(NcaSectionType.Data))
|
if (patchNca.CanOpenSection(NcaSectionType.Data))
|
||||||
{
|
{
|
||||||
dataStorage = mainNca.OpenStorageWithPatch(patchNca, NcaSectionType.Data, FsIntegrityCheckLevel);
|
dataStorage = mainNca.OpenStorageWithPatch(patchNca, NcaSectionType.Data, _device.System.FsIntegrityCheckLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patchNca.CanOpenSection(NcaSectionType.Code))
|
if (patchNca.CanOpenSection(NcaSectionType.Code))
|
||||||
{
|
{
|
||||||
codeFs = mainNca.OpenFileSystemWithPatch(patchNca, NcaSectionType.Code, FsIntegrityCheckLevel);
|
codeFs = mainNca.OpenFileSystemWithPatch(patchNca, NcaSectionType.Code, _device.System.FsIntegrityCheckLevel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -300,14 +306,9 @@ namespace Ryujinx.HLE.HOS
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataStorage == null)
|
Npdm metaData = ReadNpdm(codeFs);
|
||||||
{
|
|
||||||
Logger.PrintWarning(LogClass.Loader, "No RomFS found in NCA");
|
_fileSystem.ModLoader.CollectMods(TitleId, _fileSystem.GetBaseModsPath());
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_fileSystem.SetRomFs(dataStorage.AsStream(FileAccess.Read));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controlNca != null)
|
if (controlNca != null)
|
||||||
{
|
{
|
||||||
|
@ -318,19 +319,52 @@ namespace Ryujinx.HLE.HOS
|
||||||
ControlData.ByteSpan.Clear();
|
ControlData.ByteSpan.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadExeFs(codeFs, out _);
|
if (dataStorage == null)
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.Loader, "No RomFS found in NCA");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IStorage newStorage = _fileSystem.ModLoader.ApplyRomFsMods(TitleId, dataStorage);
|
||||||
|
_fileSystem.SetRomFs(newStorage.AsStream(FileAccess.Read));
|
||||||
|
}
|
||||||
|
|
||||||
if (TitleId != 0)
|
if (TitleId != 0)
|
||||||
{
|
{
|
||||||
EnsureSaveData(new TitleId(TitleId));
|
EnsureSaveData(new TitleId(TitleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoadExeFs(codeFs, metaData);
|
||||||
|
|
||||||
Logger.PrintInfo(LogClass.Loader, $"Application Loaded: {TitleName} v{DisplayVersion} [{TitleIdText}] [{(TitleIs64Bit ? "64-bit" : "32-bit")}]");
|
Logger.PrintInfo(LogClass.Loader, $"Application Loaded: {TitleName} v{DisplayVersion} [{TitleIdText}] [{(TitleIs64Bit ? "64-bit" : "32-bit")}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReadControlData(Nca controlNca)
|
// Sets TitleId, so be sure to call before using it
|
||||||
|
private Npdm ReadNpdm(IFileSystem fs)
|
||||||
{
|
{
|
||||||
IFileSystem controlFs = controlNca.OpenFileSystem(NcaSectionType.Data, FsIntegrityCheckLevel);
|
Result result = fs.OpenFile(out IFile npdmFile, "/main.npdm".ToU8Span(), OpenMode.Read);
|
||||||
|
Npdm metaData;
|
||||||
|
|
||||||
|
if (ResultFs.PathNotFound.Includes(result))
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.Loader, "NPDM file not found, using default values!");
|
||||||
|
|
||||||
|
metaData = GetDefaultNpdm();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
metaData = new Npdm(npdmFile.AsStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
TitleId = metaData.Aci0.TitleId;
|
||||||
|
TitleIs64Bit = metaData.Is64Bit;
|
||||||
|
|
||||||
|
return metaData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadControlData(Nca controlNca)
|
||||||
|
{
|
||||||
|
IFileSystem controlFs = controlNca.OpenFileSystem(NcaSectionType.Data, _device.System.FsIntegrityCheckLevel);
|
||||||
|
|
||||||
Result result = controlFs.OpenFile(out IFile controlFile, "/control.nacp".ToU8Span(), OpenMode.Read);
|
Result result = controlFs.OpenFile(out IFile controlFile, "/control.nacp".ToU8Span(), OpenMode.Read);
|
||||||
|
|
||||||
|
@ -358,26 +392,20 @@ namespace Ryujinx.HLE.HOS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadExeFs(IFileSystem codeFs, out Npdm metaData)
|
private void LoadExeFs(IFileSystem codeFs, Npdm metaData = null)
|
||||||
{
|
{
|
||||||
Result result = codeFs.OpenFile(out IFile npdmFile, "/main.npdm".ToU8Span(), OpenMode.Read);
|
if (_fileSystem.ModLoader.ReplaceExefsPartition(TitleId, ref codeFs))
|
||||||
|
|
||||||
if (ResultFs.PathNotFound.Includes(result))
|
|
||||||
{
|
{
|
||||||
Logger.PrintWarning(LogClass.Loader, "NPDM file not found, using default values!");
|
metaData = null; //TODO: Check if we should retain old npdm
|
||||||
|
|
||||||
metaData = GetDefaultNpdm();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
metaData = new Npdm(npdmFile.AsStream());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<IExecutable> nsos = new List<IExecutable>();
|
metaData ??= ReadNpdm(codeFs);
|
||||||
|
|
||||||
void LoadNso(string filename)
|
List<NsoExecutable> nsos = new List<NsoExecutable>();
|
||||||
|
|
||||||
|
foreach (string exePrefix in ExeFsPrefixes) // Load binaries with standard prefixes
|
||||||
{
|
{
|
||||||
foreach (DirectoryEntryEx file in codeFs.EnumerateEntries("/", $"{filename}*"))
|
foreach (DirectoryEntryEx file in codeFs.EnumerateEntries("/", exePrefix))
|
||||||
{
|
{
|
||||||
if (Path.GetExtension(file.Name) != string.Empty)
|
if (Path.GetExtension(file.Name) != string.Empty)
|
||||||
{
|
{
|
||||||
|
@ -388,25 +416,29 @@ namespace Ryujinx.HLE.HOS
|
||||||
|
|
||||||
codeFs.OpenFile(out IFile nsoFile, file.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
codeFs.OpenFile(out IFile nsoFile, file.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
|
||||||
NsoExecutable nso = new NsoExecutable(nsoFile.AsStorage());
|
NsoExecutable nso = new NsoExecutable(nsoFile.AsStorage(), file.Name);
|
||||||
|
|
||||||
nsos.Add(nso);
|
nsos.Add(nso);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TitleId = metaData.Aci0.TitleId;
|
// ExeFs file replacements
|
||||||
TitleIs64Bit = metaData.Is64Bit;
|
bool modified = _fileSystem.ModLoader.ApplyExefsMods(TitleId, nsos);
|
||||||
|
|
||||||
LoadNso("rtld");
|
var programs = nsos.ToArray();
|
||||||
LoadNso("main");
|
|
||||||
LoadNso("subsdk");
|
modified |= _fileSystem.ModLoader.ApplyNsoPatches(TitleId, programs);
|
||||||
LoadNso("sdk");
|
|
||||||
|
|
||||||
_contentManager.LoadEntries(_device);
|
_contentManager.LoadEntries(_device);
|
||||||
|
|
||||||
Ptc.Initialize(TitleIdText, DisplayVersion, EnablePtc);
|
if (EnablePtc && modified)
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.Ptc, $"Detected exefs modifications. PPTC disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
ProgramLoader.LoadNsos(_device.System.KernelContext, metaData, executables: nsos.ToArray());
|
Ptc.Initialize(TitleIdText, DisplayVersion, EnablePtc && !modified);
|
||||||
|
|
||||||
|
ProgramLoader.LoadNsos(_device.System.KernelContext, metaData, executables: programs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadProgram(string filePath)
|
public void LoadProgram(string filePath)
|
||||||
|
@ -420,7 +452,7 @@ namespace Ryujinx.HLE.HOS
|
||||||
if (isNro)
|
if (isNro)
|
||||||
{
|
{
|
||||||
FileStream input = new FileStream(filePath, FileMode.Open);
|
FileStream input = new FileStream(filePath, FileMode.Open);
|
||||||
NroExecutable obj = new NroExecutable(input);
|
NroExecutable obj = new NroExecutable(input.AsStorage());
|
||||||
executable = obj;
|
executable = obj;
|
||||||
|
|
||||||
// homebrew NRO can actually have some data after the actual NRO
|
// homebrew NRO can actually have some data after the actual NRO
|
||||||
|
@ -493,7 +525,7 @@ namespace Ryujinx.HLE.HOS
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
executable = new NsoExecutable(new LocalStorage(filePath, FileAccess.Read));
|
executable = new NsoExecutable(new LocalStorage(filePath, FileAccess.Read), Path.GetFileNameWithoutExtension(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
_contentManager.LoadEntries(_device);
|
_contentManager.LoadEntries(_device);
|
||||||
|
|
|
@ -184,11 +184,11 @@ namespace Ryujinx.HLE.HOS
|
||||||
InitLibHacHorizon();
|
InitLibHacHorizon();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadKip(string kipFile)
|
public void LoadKip(string kipPath)
|
||||||
{
|
{
|
||||||
using IStorage fs = new LocalStorage(kipFile, FileAccess.Read);
|
using IStorage kipFile = new LocalStorage(kipPath, FileAccess.Read);
|
||||||
|
|
||||||
ProgramLoader.LoadKip(KernelContext, new KipExecutable(fs));
|
ProgramLoader.LoadKip(KernelContext, new KipExecutable(kipFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitLibHacHorizon()
|
private void InitLibHacHorizon()
|
||||||
|
|
|
@ -66,7 +66,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
|
||||||
private ulong _imageSize;
|
private ulong _imageSize;
|
||||||
private ulong _mainThreadStackSize;
|
private ulong _mainThreadStackSize;
|
||||||
private ulong _memoryUsageCapacity;
|
private ulong _memoryUsageCapacity;
|
||||||
private int _category;
|
private int _version;
|
||||||
|
|
||||||
public KHandleTable HandleTable { get; private set; }
|
public KHandleTable HandleTable { get; private set; }
|
||||||
|
|
||||||
|
@ -377,7 +377,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
|
||||||
_creationTimestamp = PerformanceCounter.ElapsedMilliseconds;
|
_creationTimestamp = PerformanceCounter.ElapsedMilliseconds;
|
||||||
|
|
||||||
MmuFlags = creationInfo.MmuFlags;
|
MmuFlags = creationInfo.MmuFlags;
|
||||||
_category = creationInfo.Category;
|
_version = creationInfo.Version;
|
||||||
TitleId = creationInfo.TitleId;
|
TitleId = creationInfo.TitleId;
|
||||||
_entrypoint = creationInfo.CodeAddress;
|
_entrypoint = creationInfo.CodeAddress;
|
||||||
_imageSize = (ulong)creationInfo.CodePagesCount * KMemoryManager.PageSize;
|
_imageSize = (ulong)creationInfo.CodePagesCount * KMemoryManager.PageSize;
|
||||||
|
|
|
@ -4,7 +4,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
|
||||||
{
|
{
|
||||||
public string Name { get; private set; }
|
public string Name { get; private set; }
|
||||||
|
|
||||||
public int Category { get; private set; }
|
public int Version { get; private set; }
|
||||||
public ulong TitleId { get; private set; }
|
public ulong TitleId { get; private set; }
|
||||||
|
|
||||||
public ulong CodeAddress { get; private set; }
|
public ulong CodeAddress { get; private set; }
|
||||||
|
@ -25,7 +25,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
|
||||||
int personalMmHeapPagesCount)
|
int personalMmHeapPagesCount)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Category = category;
|
Version = category;
|
||||||
TitleId = titleId;
|
TitleId = titleId;
|
||||||
CodeAddress = codeAddress;
|
CodeAddress = codeAddress;
|
||||||
CodePagesCount = codePagesCount;
|
CodePagesCount = codePagesCount;
|
||||||
|
|
536
Ryujinx.HLE/HOS/ModLoader.cs
Normal file
536
Ryujinx.HLE/HOS/ModLoader.cs
Normal file
|
@ -0,0 +1,536 @@
|
||||||
|
using LibHac.Common;
|
||||||
|
using LibHac.Fs;
|
||||||
|
using LibHac.FsSystem;
|
||||||
|
using LibHac.FsSystem.RomFs;
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
|
using Ryujinx.HLE.Loaders.Mods;
|
||||||
|
using Ryujinx.HLE.Loaders.Executables;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Linq;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Ryujinx.HLE.HOS
|
||||||
|
{
|
||||||
|
public class ModLoader
|
||||||
|
{
|
||||||
|
private const string RomfsDir = "romfs";
|
||||||
|
private const string ExefsDir = "exefs";
|
||||||
|
private const string RomfsContainer = "romfs.bin";
|
||||||
|
private const string ExefsContainer = "exefs.nsp";
|
||||||
|
private const string StubExtension = ".stub";
|
||||||
|
|
||||||
|
private const string AmsContentsDir = "contents";
|
||||||
|
private const string AmsNsoPatchDir = "exefs_patches";
|
||||||
|
private const string AmsNroPatchDir = "nro_patches";
|
||||||
|
private const string AmsKipPatchDir = "kip_patches";
|
||||||
|
|
||||||
|
public struct Mod<T> where T : FileSystemInfo
|
||||||
|
{
|
||||||
|
public readonly string Name;
|
||||||
|
public readonly T Path;
|
||||||
|
|
||||||
|
public Mod(string name, T path)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title dependent mods
|
||||||
|
public class ModCache
|
||||||
|
{
|
||||||
|
public List<Mod<FileInfo>> RomfsContainers { get; }
|
||||||
|
public List<Mod<FileInfo>> ExefsContainers { get; }
|
||||||
|
|
||||||
|
public List<Mod<DirectoryInfo>> RomfsDirs { get; }
|
||||||
|
public List<Mod<DirectoryInfo>> ExefsDirs { get; }
|
||||||
|
|
||||||
|
public ModCache()
|
||||||
|
{
|
||||||
|
RomfsContainers = new List<Mod<FileInfo>>();
|
||||||
|
ExefsContainers = new List<Mod<FileInfo>>();
|
||||||
|
RomfsDirs = new List<Mod<DirectoryInfo>>();
|
||||||
|
ExefsDirs = new List<Mod<DirectoryInfo>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title independent mods
|
||||||
|
public class PatchCache
|
||||||
|
{
|
||||||
|
public List<Mod<DirectoryInfo>> NsoPatches { get; }
|
||||||
|
public List<Mod<DirectoryInfo>> NroPatches { get; }
|
||||||
|
public List<Mod<DirectoryInfo>> KipPatches { get; }
|
||||||
|
|
||||||
|
public HashSet<string> SearchedDirs { get; }
|
||||||
|
|
||||||
|
public PatchCache()
|
||||||
|
{
|
||||||
|
NsoPatches = new List<Mod<DirectoryInfo>>();
|
||||||
|
NroPatches = new List<Mod<DirectoryInfo>>();
|
||||||
|
KipPatches = new List<Mod<DirectoryInfo>>();
|
||||||
|
|
||||||
|
SearchedDirs = new HashSet<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<ulong, ModCache> AppMods; // key is TitleId
|
||||||
|
public PatchCache Patches;
|
||||||
|
|
||||||
|
private static readonly EnumerationOptions _dirEnumOptions;
|
||||||
|
|
||||||
|
static ModLoader()
|
||||||
|
{
|
||||||
|
_dirEnumOptions = new EnumerationOptions
|
||||||
|
{
|
||||||
|
MatchCasing = MatchCasing.CaseInsensitive,
|
||||||
|
MatchType = MatchType.Simple,
|
||||||
|
RecurseSubdirectories = false,
|
||||||
|
ReturnSpecialDirectories = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModLoader()
|
||||||
|
{
|
||||||
|
AppMods = new Dictionary<ulong, ModCache>();
|
||||||
|
Patches = new PatchCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
AppMods.Clear();
|
||||||
|
Patches = new PatchCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StrEquals(string s1, string s2) => string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void EnsureBaseDirStructure(string modsBasePath)
|
||||||
|
{
|
||||||
|
var modsDir = new DirectoryInfo(modsBasePath);
|
||||||
|
modsDir.Create();
|
||||||
|
|
||||||
|
modsDir.CreateSubdirectory(AmsContentsDir);
|
||||||
|
modsDir.CreateSubdirectory(AmsNsoPatchDir);
|
||||||
|
modsDir.CreateSubdirectory(AmsNroPatchDir);
|
||||||
|
// modsDir.CreateSubdirectory(AmsKipPatchDir); // uncomment when KIPs are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DirectoryInfo FindTitleDir(DirectoryInfo contentsDir, string titleId)
|
||||||
|
=> contentsDir.EnumerateDirectories($"{titleId}*", _dirEnumOptions).FirstOrDefault();
|
||||||
|
|
||||||
|
public string GetTitleDir(string modsBasePath, string titleId)
|
||||||
|
{
|
||||||
|
var contentsDir = new DirectoryInfo(Path.Combine(modsBasePath, AmsContentsDir));
|
||||||
|
var titleModsPath = FindTitleDir(contentsDir, titleId);
|
||||||
|
|
||||||
|
if (titleModsPath == null)
|
||||||
|
{
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Creating mods dir for Title {titleId.ToUpper()}");
|
||||||
|
titleModsPath = contentsDir.CreateSubdirectory(titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return titleModsPath.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static Query Methods
|
||||||
|
public static void QueryPatchDirs(PatchCache cache, DirectoryInfo patchDir, DirectoryInfo searchDir)
|
||||||
|
{
|
||||||
|
if (!patchDir.Exists || cache.SearchedDirs.Contains(searchDir.FullName)) return;
|
||||||
|
|
||||||
|
var patches = cache.KipPatches;
|
||||||
|
string type = null;
|
||||||
|
|
||||||
|
if (StrEquals(AmsNsoPatchDir, patchDir.Name)) { patches = cache.NsoPatches; type = "NSO"; }
|
||||||
|
else if (StrEquals(AmsNroPatchDir, patchDir.Name)) { patches = cache.NroPatches; type = "NRO"; }
|
||||||
|
else if (StrEquals(AmsKipPatchDir, patchDir.Name)) { patches = cache.KipPatches; type = "KIP"; }
|
||||||
|
else return;
|
||||||
|
|
||||||
|
foreach (var modDir in patchDir.EnumerateDirectories())
|
||||||
|
{
|
||||||
|
patches.Add(new Mod<DirectoryInfo>(modDir.Name, modDir));
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Found {type} patch '{modDir.Name}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void QueryTitleDir(ModCache mods, DirectoryInfo titleDir)
|
||||||
|
{
|
||||||
|
if (!titleDir.Exists) return;
|
||||||
|
|
||||||
|
var fsFile = new FileInfo(Path.Combine(titleDir.FullName, RomfsContainer));
|
||||||
|
if (fsFile.Exists)
|
||||||
|
{
|
||||||
|
mods.RomfsContainers.Add(new Mod<FileInfo>($"<{titleDir.Name} RomFs>", fsFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
fsFile = new FileInfo(Path.Combine(titleDir.FullName, ExefsContainer));
|
||||||
|
if (fsFile.Exists)
|
||||||
|
{
|
||||||
|
mods.ExefsContainers.Add(new Mod<FileInfo>($"<{titleDir.Name} ExeFs>", fsFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Text.StringBuilder types = new System.Text.StringBuilder(5);
|
||||||
|
|
||||||
|
foreach (var modDir in titleDir.EnumerateDirectories())
|
||||||
|
{
|
||||||
|
types.Clear();
|
||||||
|
Mod<DirectoryInfo> mod = new Mod<DirectoryInfo>("", null);
|
||||||
|
|
||||||
|
if (StrEquals(RomfsDir, modDir.Name))
|
||||||
|
{
|
||||||
|
mods.RomfsDirs.Add(mod = new Mod<DirectoryInfo>($"<{titleDir.Name} RomFs>", modDir));
|
||||||
|
types.Append('R');
|
||||||
|
}
|
||||||
|
else if (StrEquals(ExefsDir, modDir.Name))
|
||||||
|
{
|
||||||
|
mods.ExefsDirs.Add(mod = new Mod<DirectoryInfo>($"<{titleDir.Name} ExeFs>", modDir));
|
||||||
|
types.Append('E');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var romfs = new DirectoryInfo(Path.Combine(modDir.FullName, RomfsDir));
|
||||||
|
var exefs = new DirectoryInfo(Path.Combine(modDir.FullName, ExefsDir));
|
||||||
|
if (romfs.Exists)
|
||||||
|
{
|
||||||
|
mods.RomfsDirs.Add(mod = new Mod<DirectoryInfo>(modDir.Name, romfs));
|
||||||
|
types.Append('R');
|
||||||
|
}
|
||||||
|
if (exefs.Exists)
|
||||||
|
{
|
||||||
|
mods.ExefsDirs.Add(mod = new Mod<DirectoryInfo>(modDir.Name, exefs));
|
||||||
|
types.Append('E');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (types.Length > 0) Logger.PrintInfo(LogClass.ModLoader, $"Found mod '{mod.Name}' [{types}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void QueryContentsDir(ModCache mods, DirectoryInfo contentsDir, ulong titleId)
|
||||||
|
{
|
||||||
|
if (!contentsDir.Exists) return;
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Searching mods for Title {titleId:X16}");
|
||||||
|
|
||||||
|
var titleDir = FindTitleDir(contentsDir, $"{titleId:x16}");
|
||||||
|
|
||||||
|
if (titleDir != null)
|
||||||
|
{
|
||||||
|
QueryTitleDir(mods, titleDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CollectMods(ModCache mods, PatchCache patches, ulong? titleId, params string[] searchDirPaths)
|
||||||
|
{
|
||||||
|
static bool IsPatchesDir(string name) => StrEquals(AmsNsoPatchDir, name) ||
|
||||||
|
StrEquals(AmsNroPatchDir, name) ||
|
||||||
|
StrEquals(AmsKipPatchDir, name);
|
||||||
|
|
||||||
|
static bool TryQuery(ModCache mods, PatchCache patches, ulong? titleId, DirectoryInfo dir, DirectoryInfo searchDir)
|
||||||
|
{
|
||||||
|
if (StrEquals(AmsContentsDir, dir.Name))
|
||||||
|
{
|
||||||
|
if (titleId.HasValue)
|
||||||
|
{
|
||||||
|
QueryContentsDir(mods, dir, (ulong)titleId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (IsPatchesDir(dir.Name))
|
||||||
|
{
|
||||||
|
QueryPatchDirs(patches, dir, searchDir);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in searchDirPaths)
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(path);
|
||||||
|
if (!dir.Exists)
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.ModLoader, $"Mod Search Dir '{dir.FullName}' doesn't exist");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryQuery(mods, patches, titleId, dir, dir))
|
||||||
|
{
|
||||||
|
foreach (var subdir in dir.EnumerateDirectories())
|
||||||
|
{
|
||||||
|
TryQuery(mods, patches, titleId, subdir, dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.SearchedDirs.Add(dir.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CollectMods(ulong titleId, params string[] searchDirPaths)
|
||||||
|
{
|
||||||
|
if (!AppMods.TryGetValue(titleId, out ModCache mods))
|
||||||
|
{
|
||||||
|
mods = new ModCache();
|
||||||
|
AppMods[titleId] = mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectMods(mods, Patches, titleId, searchDirPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IStorage ApplyRomFsMods(ulong titleId, IStorage baseStorage)
|
||||||
|
{
|
||||||
|
if (!AppMods.TryGetValue(titleId, out ModCache mods) || mods.RomfsDirs.Count + mods.RomfsContainers.Count == 0)
|
||||||
|
{
|
||||||
|
return baseStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSet = new HashSet<string>();
|
||||||
|
var builder = new RomFsBuilder();
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Applying RomFS mods for Title {titleId:X16}");
|
||||||
|
|
||||||
|
// Prioritize loose files first
|
||||||
|
foreach (var mod in mods.RomfsDirs)
|
||||||
|
{
|
||||||
|
using (IFileSystem fs = new LocalFileSystem(mod.Path.FullName))
|
||||||
|
{
|
||||||
|
AddFiles(fs, mod.Name, fileSet, builder);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then files inside images
|
||||||
|
foreach (var mod in mods.RomfsContainers)
|
||||||
|
{
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Found 'romfs.bin' for Title {titleId:X16}");
|
||||||
|
using (IFileSystem fs = new RomFsFileSystem(mod.Path.OpenRead().AsStorage()))
|
||||||
|
{
|
||||||
|
AddFiles(fs, mod.Name, fileSet, builder);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSet.Count == 0)
|
||||||
|
{
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, "No files found. Using base RomFS");
|
||||||
|
|
||||||
|
return baseStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Replaced {fileSet.Count} file(s) over {count} mod(s). Processing base storage...");
|
||||||
|
|
||||||
|
// And finally, the base romfs
|
||||||
|
var baseRom = new RomFsFileSystem(baseStorage);
|
||||||
|
foreach (var entry in baseRom.EnumerateEntries()
|
||||||
|
.Where(f => f.Type == DirectoryEntryType.File && !fileSet.Contains(f.FullPath))
|
||||||
|
.OrderBy(f => f.FullPath, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
baseRom.OpenFile(out IFile file, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
builder.AddFile(entry.FullPath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, "Building new RomFS...");
|
||||||
|
IStorage newStorage = builder.Build();
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, "Using modded RomFS");
|
||||||
|
|
||||||
|
return newStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFiles(IFileSystem fs, string modName, HashSet<string> fileSet, RomFsBuilder builder)
|
||||||
|
{
|
||||||
|
foreach (var entry in fs.EnumerateEntries()
|
||||||
|
.Where(f => f.Type == DirectoryEntryType.File)
|
||||||
|
.OrderBy(f => f.FullPath, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
fs.OpenFile(out IFile file, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
if (fileSet.Add(entry.FullPath))
|
||||||
|
{
|
||||||
|
builder.AddFile(entry.FullPath, file);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.ModLoader, $" Skipped duplicate file '{entry.FullPath}' from '{modName}'", "ApplyRomFsMods");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool ReplaceExefsPartition(ulong titleId, ref IFileSystem exefs)
|
||||||
|
{
|
||||||
|
if (!AppMods.TryGetValue(titleId, out ModCache mods) || mods.ExefsContainers.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mods.ExefsContainers.Count > 1)
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.ModLoader, "Multiple ExeFS partition replacements detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Using replacement ExeFS partition");
|
||||||
|
|
||||||
|
exefs = new PartitionFileSystem(mods.ExefsContainers[0].Path.OpenRead().AsStorage());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool ApplyExefsMods(ulong titleId, List<NsoExecutable> nsos)
|
||||||
|
{
|
||||||
|
if (!AppMods.TryGetValue(titleId, out ModCache mods) || mods.ExefsDirs.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool replaced = false;
|
||||||
|
|
||||||
|
if (nsos.Count > 32)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException("NSO Count is more than 32");
|
||||||
|
}
|
||||||
|
|
||||||
|
var exeMods = mods.ExefsDirs;
|
||||||
|
|
||||||
|
BitVector32 stubs = new BitVector32();
|
||||||
|
BitVector32 repls = new BitVector32();
|
||||||
|
|
||||||
|
foreach (var mod in exeMods)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < nsos.Count; ++i)
|
||||||
|
{
|
||||||
|
var nso = nsos[i];
|
||||||
|
var nsoName = nso.Name;
|
||||||
|
|
||||||
|
FileInfo nsoFile = new FileInfo(Path.Combine(mod.Path.FullName, nsoName));
|
||||||
|
if (nsoFile.Exists)
|
||||||
|
{
|
||||||
|
if (repls[1 << i])
|
||||||
|
{
|
||||||
|
Logger.PrintWarning(LogClass.ModLoader, $"Multiple replacements to '{nsoName}'");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
repls[1 << i] = true;
|
||||||
|
|
||||||
|
nsos[i] = new NsoExecutable(nsoFile.OpenRead().AsStorage(), nsoName);
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"NSO '{nsoName}' replaced");
|
||||||
|
|
||||||
|
replaced = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stubs[1 << i] |= File.Exists(Path.Combine(mod.Path.FullName, nsoName + StubExtension));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = nsos.Count - 1; i >= 0; --i)
|
||||||
|
{
|
||||||
|
if (stubs[1 << i] && !repls[1 << i]) // Prioritizes replacements over stubs
|
||||||
|
{
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $" NSO '{nsos[i].Name}' stubbed");
|
||||||
|
nsos.RemoveAt(i);
|
||||||
|
replaced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ApplyNroPatches(NroExecutable nro)
|
||||||
|
{
|
||||||
|
var nroPatches = Patches.NroPatches;
|
||||||
|
|
||||||
|
if (nroPatches.Count == 0) return;
|
||||||
|
|
||||||
|
// NRO patches aren't offset relative to header unlike NSO
|
||||||
|
// according to Atmosphere's ro patcher module
|
||||||
|
ApplyProgramPatches(nroPatches, 0, nro);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool ApplyNsoPatches(ulong titleId, params IExecutable[] programs)
|
||||||
|
{
|
||||||
|
AppMods.TryGetValue(titleId, out ModCache mods);
|
||||||
|
var nsoMods = Patches.NsoPatches.Concat(mods.ExefsDirs);
|
||||||
|
|
||||||
|
// NSO patches are created with offset 0 according to Atmosphere's patcher module
|
||||||
|
// But `Program` doesn't contain the header which is 0x100 bytes. So, we adjust for that here
|
||||||
|
return ApplyProgramPatches(nsoMods, 0x100, programs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ApplyProgramPatches(IEnumerable<Mod<DirectoryInfo>> mods, int protectedOffset, params IExecutable[] programs)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
MemPatch[] patches = new MemPatch[programs.Length];
|
||||||
|
|
||||||
|
for (int i = 0; i < patches.Length; ++i)
|
||||||
|
{
|
||||||
|
patches[i] = new MemPatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
var buildIds = programs.Select(p => p switch
|
||||||
|
{
|
||||||
|
NsoExecutable nso => BitConverter.ToString(nso.BuildId.Bytes.ToArray()).Replace("-", "").TrimEnd('0'),
|
||||||
|
NroExecutable nro => BitConverter.ToString(nro.Header.BuildId).Replace("-", "").TrimEnd('0'),
|
||||||
|
_ => string.Empty
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
int GetIndex(string buildId) => buildIds.FindIndex(id => id == buildId); // O(n) but list is small
|
||||||
|
|
||||||
|
// Collect patches
|
||||||
|
foreach (var mod in mods)
|
||||||
|
{
|
||||||
|
var patchDir = mod.Path;
|
||||||
|
foreach (var patchFile in patchDir.EnumerateFiles())
|
||||||
|
{
|
||||||
|
if (StrEquals(".ips", patchFile.Extension)) // IPS|IPS32
|
||||||
|
{
|
||||||
|
string filename = Path.GetFileNameWithoutExtension(patchFile.FullName).Split('.')[0];
|
||||||
|
string buildId = filename.TrimEnd('0');
|
||||||
|
|
||||||
|
int index = GetIndex(buildId);
|
||||||
|
if (index == -1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Matching IPS patch '{patchFile.Name}' in '{mod.Name}' bid={buildId}");
|
||||||
|
|
||||||
|
using var fs = patchFile.OpenRead();
|
||||||
|
using var reader = new BinaryReader(fs);
|
||||||
|
|
||||||
|
var patcher = new IpsPatcher(reader);
|
||||||
|
patcher.AddPatches(patches[index]);
|
||||||
|
}
|
||||||
|
else if (StrEquals(".pchtxt", patchFile.Extension)) // IPSwitch
|
||||||
|
{
|
||||||
|
using var fs = patchFile.OpenRead();
|
||||||
|
using var reader = new StreamReader(fs);
|
||||||
|
|
||||||
|
var patcher = new IPSwitchPatcher(reader);
|
||||||
|
|
||||||
|
int index = GetIndex(patcher.BuildId);
|
||||||
|
if (index == -1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Matching IPSwitch patch '{patchFile.Name}' in '{mod.Name}' bid={patcher.BuildId}");
|
||||||
|
|
||||||
|
patcher.AddPatches(patches[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply patches
|
||||||
|
for (int i = 0; i < programs.Length; ++i)
|
||||||
|
{
|
||||||
|
count += patches[i].Patch(programs[i].Program, protectedOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -179,7 +179,7 @@ namespace Ryujinx.HLE.HOS
|
||||||
|
|
||||||
ProcessCreationInfo creationInfo = new ProcessCreationInfo(
|
ProcessCreationInfo creationInfo = new ProcessCreationInfo(
|
||||||
metaData.TitleName,
|
metaData.TitleName,
|
||||||
metaData.ProcessCategory,
|
metaData.Version,
|
||||||
metaData.Aci0.TitleId,
|
metaData.Aci0.TitleId,
|
||||||
codeStart,
|
codeStart,
|
||||||
codePagesCount,
|
codePagesCount,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Ryujinx.Common;
|
using LibHac.FsSystem;
|
||||||
|
using Ryujinx.Common;
|
||||||
using Ryujinx.Cpu;
|
using Ryujinx.Cpu;
|
||||||
using Ryujinx.HLE.HOS.Kernel.Common;
|
using Ryujinx.HLE.HOS.Kernel.Common;
|
||||||
using Ryujinx.HLE.HOS.Kernel.Memory;
|
using Ryujinx.HLE.HOS.Kernel.Memory;
|
||||||
|
@ -163,33 +164,36 @@ namespace Ryujinx.HLE.HOS.Services.Ro
|
||||||
|
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
|
|
||||||
NroExecutable executable = new NroExecutable(stream, nroAddress, bssAddress);
|
NroExecutable nro = new NroExecutable(stream.AsStorage(), nroAddress, bssAddress);
|
||||||
|
|
||||||
// check if everything is page align.
|
// Check if everything is page align.
|
||||||
if ((executable.Text.Length & 0xFFF) != 0 || (executable.Ro.Length & 0xFFF) != 0 ||
|
if ((nro.Text.Length & 0xFFF) != 0 || (nro.Ro.Length & 0xFFF) != 0 ||
|
||||||
(executable.Data.Length & 0xFFF) != 0 || (executable.BssSize & 0xFFF) != 0)
|
(nro.Data.Length & 0xFFF) != 0 || (nro.BssSize & 0xFFF) != 0)
|
||||||
{
|
{
|
||||||
return ResultCode.InvalidNro;
|
return ResultCode.InvalidNro;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if everything is contiguous.
|
// Check if everything is contiguous.
|
||||||
if (executable.RoOffset != executable.TextOffset + executable.Text.Length ||
|
if (nro.RoOffset != nro.TextOffset + nro.Text.Length ||
|
||||||
executable.DataOffset != executable.RoOffset + executable.Ro.Length ||
|
nro.DataOffset != nro.RoOffset + nro.Ro.Length ||
|
||||||
nroFileSize != executable.DataOffset + executable.Data.Length)
|
nroFileSize != nro.DataOffset + nro.Data.Length)
|
||||||
{
|
{
|
||||||
return ResultCode.InvalidNro;
|
return ResultCode.InvalidNro;
|
||||||
}
|
}
|
||||||
|
|
||||||
// finally check the bss size match.
|
// Check the bss size match.
|
||||||
if ((ulong)executable.BssSize != bssSize)
|
if ((ulong)nro.BssSize != bssSize)
|
||||||
{
|
{
|
||||||
return ResultCode.InvalidNro;
|
return ResultCode.InvalidNro;
|
||||||
}
|
}
|
||||||
|
|
||||||
int totalSize = executable.Text.Length + executable.Ro.Length + executable.Data.Length + executable.BssSize;
|
int totalSize = nro.Text.Length + nro.Ro.Length + nro.Data.Length + nro.BssSize;
|
||||||
|
|
||||||
|
// Apply patches
|
||||||
|
context.Device.FileSystem.ModLoader.ApplyNroPatches(nro);
|
||||||
|
|
||||||
res = new NroInfo(
|
res = new NroInfo(
|
||||||
executable,
|
nro,
|
||||||
nroHash,
|
nroHash,
|
||||||
nroAddress,
|
nroAddress,
|
||||||
nroSize,
|
nroSize,
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Ryujinx.HLE.Loaders.Executables
|
namespace Ryujinx.HLE.Loaders.Executables
|
||||||
{
|
{
|
||||||
interface IExecutable
|
interface IExecutable
|
||||||
{
|
{
|
||||||
byte[] Text { get; }
|
byte[] Program { get; }
|
||||||
byte[] Ro { get; }
|
Span<byte> Text { get; }
|
||||||
byte[] Data { get; }
|
Span<byte> Ro { get; }
|
||||||
|
Span<byte> Data { get; }
|
||||||
|
|
||||||
int TextOffset { get; }
|
int TextOffset { get; }
|
||||||
int RoOffset { get; }
|
int RoOffset { get; }
|
||||||
|
|
|
@ -1,18 +1,24 @@
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.Loader;
|
using LibHac.Loader;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Ryujinx.HLE.Loaders.Executables
|
namespace Ryujinx.HLE.Loaders.Executables
|
||||||
{
|
{
|
||||||
class KipExecutable : IExecutable
|
class KipExecutable : IExecutable
|
||||||
{
|
{
|
||||||
public byte[] Text { get; }
|
public byte[] Program { get; }
|
||||||
public byte[] Ro { get; }
|
public Span<byte> Text => Program.AsSpan().Slice(TextOffset, TextSize);
|
||||||
public byte[] Data { get; }
|
public Span<byte> Ro => Program.AsSpan().Slice(RoOffset, RoSize);
|
||||||
|
public Span<byte> Data => Program.AsSpan().Slice(DataOffset, DataSize);
|
||||||
|
|
||||||
public int TextOffset { get; }
|
public int TextOffset { get; }
|
||||||
public int RoOffset { get; }
|
public int RoOffset { get; }
|
||||||
public int DataOffset { get; }
|
public int DataOffset { get; }
|
||||||
public int BssOffset { get; }
|
public int BssOffset { get; }
|
||||||
|
|
||||||
|
public int TextSize { get; }
|
||||||
|
public int RoSize { get; }
|
||||||
|
public int DataSize { get; }
|
||||||
public int BssSize { get; }
|
public int BssSize { get; }
|
||||||
|
|
||||||
public int[] Capabilities { get; }
|
public int[] Capabilities { get; }
|
||||||
|
@ -25,7 +31,6 @@ namespace Ryujinx.HLE.Loaders.Executables
|
||||||
public byte IdealCoreId { get; }
|
public byte IdealCoreId { get; }
|
||||||
public int Version { get; }
|
public int Version { get; }
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|
||||||
public KipExecutable(IStorage inStorage)
|
public KipExecutable(IStorage inStorage)
|
||||||
{
|
{
|
||||||
KipReader reader = new KipReader();
|
KipReader reader = new KipReader();
|
||||||
|
@ -57,20 +62,23 @@ namespace Ryujinx.HLE.Loaders.Executables
|
||||||
Capabilities[index] = (int)reader.Capabilities[index];
|
Capabilities[index] = (int)reader.Capabilities[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
Text = DecompressSection(reader, KipReader.SegmentType.Text);
|
reader.GetSegmentSize(KipReader.SegmentType.Data, out int uncompressedSize).ThrowIfFailure();
|
||||||
Ro = DecompressSection(reader, KipReader.SegmentType.Ro);
|
Program = new byte[DataOffset + uncompressedSize];
|
||||||
Data = DecompressSection(reader, KipReader.SegmentType.Data);
|
|
||||||
|
TextSize = DecompressSection(reader, KipReader.SegmentType.Text, TextOffset, Program);
|
||||||
|
RoSize = DecompressSection(reader, KipReader.SegmentType.Ro, RoOffset, Program);
|
||||||
|
DataSize = DecompressSection(reader, KipReader.SegmentType.Data, DataOffset, Program);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] DecompressSection(KipReader reader, KipReader.SegmentType segmentType)
|
private static int DecompressSection(KipReader reader, KipReader.SegmentType segmentType, int offset, byte[] Program)
|
||||||
{
|
{
|
||||||
reader.GetSegmentSize(segmentType, out int uncompressedSize).ThrowIfFailure();
|
reader.GetSegmentSize(segmentType, out int uncompressedSize).ThrowIfFailure();
|
||||||
|
|
||||||
byte[] result = new byte[uncompressedSize];
|
var span = Program.AsSpan().Slice(offset, uncompressedSize);
|
||||||
|
|
||||||
reader.ReadSegment(segmentType, result).ThrowIfFailure();
|
reader.ReadSegment(segmentType, span).ThrowIfFailure();
|
||||||
|
|
||||||
return result;
|
return uncompressedSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,67 +1,38 @@
|
||||||
using System.IO;
|
using LibHac;
|
||||||
|
using LibHac.Fs;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Ryujinx.HLE.Loaders.Executables
|
namespace Ryujinx.HLE.Loaders.Executables
|
||||||
{
|
{
|
||||||
class NroExecutable : IExecutable
|
class NroExecutable : Nro, IExecutable
|
||||||
{
|
{
|
||||||
public byte[] Text { get; private set; }
|
public byte[] Program { get; }
|
||||||
public byte[] Ro { get; private set; }
|
public Span<byte> Text => Program.AsSpan().Slice(TextOffset, (int)Header.NroSegments[0].Size);
|
||||||
public byte[] Data { get; private set; }
|
public Span<byte> Ro => Program.AsSpan().Slice(RoOffset, (int)Header.NroSegments[1].Size);
|
||||||
|
public Span<byte> Data => Program.AsSpan().Slice(DataOffset, (int)Header.NroSegments[2].Size);
|
||||||
|
|
||||||
public int Mod0Offset { get; private set; }
|
public int TextOffset => (int)Header.NroSegments[0].FileOffset;
|
||||||
public int TextOffset { get; private set; }
|
public int RoOffset => (int)Header.NroSegments[1].FileOffset;
|
||||||
public int RoOffset { get; private set; }
|
public int DataOffset => (int)Header.NroSegments[2].FileOffset;
|
||||||
public int DataOffset { get; private set; }
|
public int BssOffset => DataOffset + Data.Length;
|
||||||
public int BssSize { get; private set; }
|
public int BssSize => (int)Header.BssSize;
|
||||||
public int FileSize { get; private set; }
|
|
||||||
|
|
||||||
public int BssOffset => DataOffset + Data.Length;
|
public int Mod0Offset => Start.Mod0Offset;
|
||||||
|
public int FileSize => (int)Header.Size;
|
||||||
|
|
||||||
public ulong SourceAddress { get; private set; }
|
public ulong SourceAddress { get; private set; }
|
||||||
public ulong BssAddress { get; private set; }
|
public ulong BssAddress { get; private set; }
|
||||||
|
|
||||||
public NroExecutable(Stream input, ulong sourceAddress = 0, ulong bssAddress = 0)
|
public NroExecutable(IStorage inStorage, ulong sourceAddress = 0, ulong bssAddress = 0) : base(inStorage)
|
||||||
{
|
{
|
||||||
|
Program = new byte[FileSize];
|
||||||
|
|
||||||
SourceAddress = sourceAddress;
|
SourceAddress = sourceAddress;
|
||||||
BssAddress = bssAddress;
|
BssAddress = bssAddress;
|
||||||
|
|
||||||
BinaryReader reader = new BinaryReader(input);
|
OpenNroSegment(NroSegmentType.Text, false).Read(0, Text);
|
||||||
|
OpenNroSegment(NroSegmentType.Ro , false).Read(0, Ro);
|
||||||
input.Seek(4, SeekOrigin.Begin);
|
OpenNroSegment(NroSegmentType.Data, false).Read(0, Data);
|
||||||
|
|
||||||
int mod0Offset = reader.ReadInt32();
|
|
||||||
int padding8 = reader.ReadInt32();
|
|
||||||
int paddingC = reader.ReadInt32();
|
|
||||||
int nroMagic = reader.ReadInt32();
|
|
||||||
int unknown14 = reader.ReadInt32();
|
|
||||||
int fileSize = reader.ReadInt32();
|
|
||||||
int unknown1C = reader.ReadInt32();
|
|
||||||
int textOffset = reader.ReadInt32();
|
|
||||||
int textSize = reader.ReadInt32();
|
|
||||||
int roOffset = reader.ReadInt32();
|
|
||||||
int roSize = reader.ReadInt32();
|
|
||||||
int dataOffset = reader.ReadInt32();
|
|
||||||
int dataSize = reader.ReadInt32();
|
|
||||||
int bssSize = reader.ReadInt32();
|
|
||||||
|
|
||||||
Mod0Offset = mod0Offset;
|
|
||||||
TextOffset = textOffset;
|
|
||||||
RoOffset = roOffset;
|
|
||||||
DataOffset = dataOffset;
|
|
||||||
BssSize = bssSize;
|
|
||||||
|
|
||||||
byte[] Read(long position, int size)
|
|
||||||
{
|
|
||||||
input.Seek(position, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
return reader.ReadBytes(size);
|
|
||||||
}
|
|
||||||
|
|
||||||
Text = Read(textOffset, textSize);
|
|
||||||
Ro = Read(roOffset, roSize);
|
|
||||||
Data = Read(dataOffset, dataSize);
|
|
||||||
|
|
||||||
FileSize = fileSize;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,23 +1,32 @@
|
||||||
|
using LibHac.Common;
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.FsSystem;
|
using LibHac.FsSystem;
|
||||||
using LibHac.Loader;
|
using LibHac.Loader;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Ryujinx.HLE.Loaders.Executables
|
namespace Ryujinx.HLE.Loaders.Executables
|
||||||
{
|
{
|
||||||
class NsoExecutable : IExecutable
|
class NsoExecutable : IExecutable
|
||||||
{
|
{
|
||||||
public byte[] Text { get; }
|
public byte[] Program { get; }
|
||||||
public byte[] Ro { get; }
|
public Span<byte> Text => Program.AsSpan().Slice(TextOffset, TextSize);
|
||||||
public byte[] Data { get; }
|
public Span<byte> Ro => Program.AsSpan().Slice(RoOffset, RoSize);
|
||||||
|
public Span<byte> Data => Program.AsSpan().Slice(DataOffset, DataSize);
|
||||||
|
|
||||||
public int TextOffset { get; }
|
public int TextOffset { get; }
|
||||||
public int RoOffset { get; }
|
public int RoOffset { get; }
|
||||||
public int DataOffset { get; }
|
public int DataOffset { get; }
|
||||||
public int BssOffset => DataOffset + Data.Length;
|
public int BssOffset => DataOffset + Data.Length;
|
||||||
|
|
||||||
|
public int TextSize { get; }
|
||||||
|
public int RoSize { get; }
|
||||||
|
public int DataSize { get; }
|
||||||
public int BssSize { get; }
|
public int BssSize { get; }
|
||||||
|
|
||||||
public NsoExecutable(IStorage inStorage)
|
public string Name;
|
||||||
|
public Buffer32 BuildId;
|
||||||
|
|
||||||
|
public NsoExecutable(IStorage inStorage, string name = null)
|
||||||
{
|
{
|
||||||
NsoReader reader = new NsoReader();
|
NsoReader reader = new NsoReader();
|
||||||
|
|
||||||
|
@ -28,20 +37,26 @@ namespace Ryujinx.HLE.Loaders.Executables
|
||||||
DataOffset = (int)reader.Header.Segments[2].MemoryOffset;
|
DataOffset = (int)reader.Header.Segments[2].MemoryOffset;
|
||||||
BssSize = (int)reader.Header.BssSize;
|
BssSize = (int)reader.Header.BssSize;
|
||||||
|
|
||||||
Text = DecompressSection(reader, NsoReader.SegmentType.Text);
|
reader.GetSegmentSize(NsoReader.SegmentType.Data, out uint uncompressedSize).ThrowIfFailure();
|
||||||
Ro = DecompressSection(reader, NsoReader.SegmentType.Ro);
|
Program = new byte[DataOffset + uncompressedSize];
|
||||||
Data = DecompressSection(reader, NsoReader.SegmentType.Data);
|
|
||||||
|
TextSize = DecompressSection(reader, NsoReader.SegmentType.Text, TextOffset, Program);
|
||||||
|
RoSize = DecompressSection(reader, NsoReader.SegmentType.Ro, RoOffset, Program);
|
||||||
|
DataSize = DecompressSection(reader, NsoReader.SegmentType.Data, DataOffset, Program);
|
||||||
|
|
||||||
|
Name = name;
|
||||||
|
BuildId = reader.Header.ModuleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] DecompressSection(NsoReader reader, NsoReader.SegmentType segmentType)
|
private static int DecompressSection(NsoReader reader, NsoReader.SegmentType segmentType, int offset, byte[] Program)
|
||||||
{
|
{
|
||||||
reader.GetSegmentSize(segmentType, out uint uncompressedSize).ThrowIfFailure();
|
reader.GetSegmentSize(segmentType, out uint uncompressedSize).ThrowIfFailure();
|
||||||
|
|
||||||
byte[] result = new byte[uncompressedSize];
|
var span = Program.AsSpan().Slice(offset, (int)uncompressedSize);
|
||||||
|
|
||||||
reader.ReadSegment(segmentType, result).ThrowIfFailure();
|
reader.ReadSegment(segmentType, span).ThrowIfFailure();
|
||||||
|
|
||||||
return result;
|
return (int)uncompressedSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
117
Ryujinx.HLE/Loaders/Mods/IPSPatcher.cs
Normal file
117
Ryujinx.HLE/Loaders/Mods/IPSPatcher.cs
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ryujinx.HLE.Loaders.Mods
|
||||||
|
{
|
||||||
|
class IpsPatcher
|
||||||
|
{
|
||||||
|
MemPatch _patches;
|
||||||
|
|
||||||
|
public IpsPatcher(BinaryReader reader)
|
||||||
|
{
|
||||||
|
_patches = ParseIps(reader);
|
||||||
|
if (_patches != null)
|
||||||
|
{
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, "IPS patch loaded successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemPatch ParseIps(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Span<byte> IpsHeaderMagic = Encoding.ASCII.GetBytes("PATCH").AsSpan();
|
||||||
|
Span<byte> IpsTailMagic = Encoding.ASCII.GetBytes("EOF").AsSpan();
|
||||||
|
Span<byte> Ips32HeaderMagic = Encoding.ASCII.GetBytes("IPS32").AsSpan();
|
||||||
|
Span<byte> Ips32TailMagic = Encoding.ASCII.GetBytes("EEOF").AsSpan();
|
||||||
|
|
||||||
|
MemPatch patches = new MemPatch();
|
||||||
|
var header = reader.ReadBytes(IpsHeaderMagic.Length).AsSpan();
|
||||||
|
|
||||||
|
if (header.Length != IpsHeaderMagic.Length)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is32;
|
||||||
|
Span<byte> tailSpan;
|
||||||
|
|
||||||
|
if (header.SequenceEqual(IpsHeaderMagic))
|
||||||
|
{
|
||||||
|
is32 = false;
|
||||||
|
tailSpan = IpsTailMagic;
|
||||||
|
}
|
||||||
|
else if (header.SequenceEqual(Ips32HeaderMagic))
|
||||||
|
{
|
||||||
|
is32 = true;
|
||||||
|
tailSpan = Ips32TailMagic;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buf = new byte[tailSpan.Length];
|
||||||
|
|
||||||
|
bool ReadNext(int size) => reader.Read(buf, 0, size) != size;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (ReadNext(buf.Length))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.AsSpan().SequenceEqual(tailSpan))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int patchOffset = is32 ? buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]
|
||||||
|
: buf[0] << 16 | buf[1] << 8 | buf[2];
|
||||||
|
|
||||||
|
if (ReadNext(2))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int patchSize = buf[0] << 8 | buf[1];
|
||||||
|
|
||||||
|
if (patchSize == 0) // RLE/Fill mode
|
||||||
|
{
|
||||||
|
if (ReadNext(2))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fillLength = buf[0] << 8 | buf[1];
|
||||||
|
|
||||||
|
if (ReadNext(1))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.AddFill((uint)patchOffset, fillLength, buf[0]);
|
||||||
|
}
|
||||||
|
else // Copy mode
|
||||||
|
{
|
||||||
|
var patch = reader.ReadBytes(patchSize);
|
||||||
|
|
||||||
|
if (patch.Length != patchSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.Add((uint)patchOffset, patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddPatches(MemPatch patches)
|
||||||
|
{
|
||||||
|
patches.AddFrom(_patches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
262
Ryujinx.HLE/Loaders/Mods/IPSwitchPatcher.cs
Normal file
262
Ryujinx.HLE/Loaders/Mods/IPSwitchPatcher.cs
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ryujinx.HLE.Loaders.Mods
|
||||||
|
{
|
||||||
|
class IPSwitchPatcher
|
||||||
|
{
|
||||||
|
readonly StreamReader _reader;
|
||||||
|
public string BuildId { get; }
|
||||||
|
|
||||||
|
const string BidHeader = "@nsobid-";
|
||||||
|
|
||||||
|
public IPSwitchPatcher(StreamReader reader)
|
||||||
|
{
|
||||||
|
string header = reader.ReadLine();
|
||||||
|
if (header == null || !header.StartsWith(BidHeader))
|
||||||
|
{
|
||||||
|
Logger.PrintError(LogClass.ModLoader, "IPSwitch: Malformed PCHTXT file. Skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reader = reader;
|
||||||
|
BuildId = header.Substring(BidHeader.Length).TrimEnd().TrimEnd('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Token
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
String,
|
||||||
|
EscapeChar,
|
||||||
|
Comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomments line and unescapes C style strings within
|
||||||
|
private static string PreprocessLine(string line)
|
||||||
|
{
|
||||||
|
StringBuilder str = new StringBuilder();
|
||||||
|
Token state = Token.Normal;
|
||||||
|
|
||||||
|
for (int i = 0; i < line.Length; ++i)
|
||||||
|
{
|
||||||
|
char c = line[i];
|
||||||
|
char la = i + 1 != line.Length ? line[i + 1] : '\0';
|
||||||
|
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case Token.Normal:
|
||||||
|
state = c == '"' ? Token.String :
|
||||||
|
c == '/' && la == '/' ? Token.Comment :
|
||||||
|
c == '/' && la != '/' ? Token.Comment : // Ignore error and stop parsing
|
||||||
|
Token.Normal;
|
||||||
|
break;
|
||||||
|
case Token.String:
|
||||||
|
state = c switch
|
||||||
|
{
|
||||||
|
'"' => Token.Normal,
|
||||||
|
'\\' => Token.EscapeChar,
|
||||||
|
_ => Token.String
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case Token.EscapeChar:
|
||||||
|
state = Token.String;
|
||||||
|
c = c switch
|
||||||
|
{
|
||||||
|
'a' => '\a',
|
||||||
|
'b' => '\b',
|
||||||
|
'f' => '\f',
|
||||||
|
'n' => '\n',
|
||||||
|
'r' => '\r',
|
||||||
|
't' => '\t',
|
||||||
|
'v' => '\v',
|
||||||
|
'\\' => '\\',
|
||||||
|
_ => '?'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == Token.Comment) break;
|
||||||
|
|
||||||
|
if (state < Token.EscapeChar)
|
||||||
|
{
|
||||||
|
str.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ParseHexByte(byte c)
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
{
|
||||||
|
return c - '0';
|
||||||
|
}
|
||||||
|
else if (c >= 'A' && c <= 'F')
|
||||||
|
{
|
||||||
|
return c - 'A' + 10;
|
||||||
|
}
|
||||||
|
else if (c >= 'a' && c <= 'f')
|
||||||
|
{
|
||||||
|
return c - 'a' + 10;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Big Endian
|
||||||
|
static byte[] Hex2ByteArrayBE(string hexstr)
|
||||||
|
{
|
||||||
|
if ((hexstr.Length & 1) == 1) return null;
|
||||||
|
|
||||||
|
byte[] bytes = new byte[hexstr.Length >> 1];
|
||||||
|
|
||||||
|
for (int i = 0; i < hexstr.Length; i += 2)
|
||||||
|
{
|
||||||
|
int high = ParseHexByte((byte)hexstr[i]);
|
||||||
|
int low = ParseHexByte((byte)hexstr[i + 1]);
|
||||||
|
bytes[i >> 1] = (byte)((high << 4) | low);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto base discovery
|
||||||
|
private static bool ParseInt(string str, out int value)
|
||||||
|
{
|
||||||
|
if (str[0] == '0' && (str[1] == 'x' || str[1] == 'X'))
|
||||||
|
{
|
||||||
|
return Int32.TryParse(str.Substring(2), System.Globalization.NumberStyles.HexNumber, null, out value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Int32.TryParse(str, System.Globalization.NumberStyles.Integer, null, out value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MemPatch Parse()
|
||||||
|
{
|
||||||
|
if (_reader == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
MemPatch patches = new MemPatch();
|
||||||
|
|
||||||
|
bool enabled = true;
|
||||||
|
bool printValues = false;
|
||||||
|
int offset_shift = 0;
|
||||||
|
|
||||||
|
string line;
|
||||||
|
int lineNum = 0;
|
||||||
|
|
||||||
|
static void Print(string s) => Logger.PrintInfo(LogClass.ModLoader, $"IPSwitch: {s}");
|
||||||
|
|
||||||
|
void ParseWarn() => Logger.PrintWarning(LogClass.ModLoader, $"IPSwitch: Parse error at line {lineNum} for bid={BuildId}");
|
||||||
|
|
||||||
|
while ((line = _reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
line = PreprocessLine(line);
|
||||||
|
lineNum += 1;
|
||||||
|
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
Print(line);
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("@stop"))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("@enabled"))
|
||||||
|
{
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("@disabled"))
|
||||||
|
{
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("@flag"))
|
||||||
|
{
|
||||||
|
var tokens = line.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
if (tokens.Length < 2)
|
||||||
|
{
|
||||||
|
ParseWarn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens[1] == "offset_shift")
|
||||||
|
{
|
||||||
|
if (tokens.Length != 3 || !ParseInt(tokens[2], out offset_shift))
|
||||||
|
{
|
||||||
|
ParseWarn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (tokens[1] == "print_values")
|
||||||
|
{
|
||||||
|
printValues = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (line.StartsWith('@'))
|
||||||
|
{
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
if (tokens.Length < 2)
|
||||||
|
{
|
||||||
|
ParseWarn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Int32.TryParse(tokens[0], System.Globalization.NumberStyles.HexNumber, null, out int offset))
|
||||||
|
{
|
||||||
|
ParseWarn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += offset_shift;
|
||||||
|
|
||||||
|
if (printValues)
|
||||||
|
{
|
||||||
|
Print($"print_values 0x{offset:x} <= {tokens[1]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens[1][0] == '"')
|
||||||
|
{
|
||||||
|
var patch = Encoding.ASCII.GetBytes(tokens[1].Trim('"'));
|
||||||
|
patches.Add((uint)offset, patch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var patch = Hex2ByteArrayBE(tokens[1]);
|
||||||
|
patches.Add((uint)offset, patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddPatches(MemPatch patches)
|
||||||
|
{
|
||||||
|
patches.AddFrom(Parse());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
98
Ryujinx.HLE/Loaders/Mods/MemPatch.cs
Normal file
98
Ryujinx.HLE/Loaders/Mods/MemPatch.cs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
using Ryujinx.Cpu;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
|
|
||||||
|
namespace Ryujinx.HLE.Loaders.Mods
|
||||||
|
{
|
||||||
|
public class MemPatch
|
||||||
|
{
|
||||||
|
readonly Dictionary<uint, byte[]> _patches = new Dictionary<uint, byte[]>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a patch to specified offset. Overwrites if already present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="offset">Memory offset</param>
|
||||||
|
/// <param name="patch">The patch to add</param>
|
||||||
|
public void Add(uint offset, byte[] patch)
|
||||||
|
{
|
||||||
|
_patches[offset] = patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a patch in the form of an RLE (Fill mode).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="offset">Memory offset</param>
|
||||||
|
/// <param name="length"The fill length</param>
|
||||||
|
/// <param name="filler">The byte to fill</param>
|
||||||
|
public void AddFill(uint offset, int length, byte filler)
|
||||||
|
{
|
||||||
|
// TODO: Can be made space efficient by changing `_patches`
|
||||||
|
// Should suffice for now
|
||||||
|
byte[] patch = new byte[length];
|
||||||
|
patch.AsSpan().Fill(filler);
|
||||||
|
|
||||||
|
_patches[offset] = patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds all patches from an existing MemPatch
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="patches">The patches to add</param>
|
||||||
|
public void AddFrom(MemPatch patches)
|
||||||
|
{
|
||||||
|
if (patches == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (patchOffset, patch) in patches._patches)
|
||||||
|
{
|
||||||
|
_patches[patchOffset] = patch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies all the patches added to this instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Patches are applied in ascending order of offsets to guarantee
|
||||||
|
/// overlapping patches always apply the same way.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="memory">The span of bytes to patch</param>
|
||||||
|
/// <param name="maxSize">The maximum size of the slice of patchable memory</param>
|
||||||
|
/// <param name="protectedOffset">A secondary offset used in special cases (NSO header)</param>
|
||||||
|
/// <returns>Successful patches count</returns>
|
||||||
|
public int Patch(Span<byte> memory, int protectedOffset = 0)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
foreach (var (offset, patch) in _patches.OrderBy(item => item.Key))
|
||||||
|
{
|
||||||
|
int patchOffset = (int)offset;
|
||||||
|
int patchSize = patch.Length;
|
||||||
|
|
||||||
|
if (patchOffset < protectedOffset || patchOffset > memory.Length)
|
||||||
|
{
|
||||||
|
continue; // Add warning?
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOffset -= protectedOffset;
|
||||||
|
|
||||||
|
if (patchOffset + patchSize > memory.Length)
|
||||||
|
{
|
||||||
|
patchSize = memory.Length - (int)patchOffset; // Add warning?
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.PrintInfo(LogClass.ModLoader, $"Patching address offset {patchOffset:x} <= {BitConverter.ToString(patch).Replace('-', ' ')} len={patchSize}");
|
||||||
|
|
||||||
|
patch.AsSpan().Slice(0, patchSize).CopyTo(memory.Slice(patchOffset, patchSize));
|
||||||
|
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ namespace Ryujinx.HLE.Loaders.Npdm
|
||||||
public byte MainThreadPriority { get; private set; }
|
public byte MainThreadPriority { get; private set; }
|
||||||
public byte DefaultCpuId { get; private set; }
|
public byte DefaultCpuId { get; private set; }
|
||||||
public int PersonalMmHeapSize { get; private set; }
|
public int PersonalMmHeapSize { get; private set; }
|
||||||
public int ProcessCategory { get; private set; }
|
public int Version { get; private set; }
|
||||||
public int MainThreadStackSize { get; private set; }
|
public int MainThreadStackSize { get; private set; }
|
||||||
public string TitleName { get; set; }
|
public string TitleName { get; set; }
|
||||||
public byte[] ProductCode { get; private set; }
|
public byte[] ProductCode { get; private set; }
|
||||||
|
@ -48,7 +48,7 @@ namespace Ryujinx.HLE.Loaders.Npdm
|
||||||
|
|
||||||
PersonalMmHeapSize = reader.ReadInt32();
|
PersonalMmHeapSize = reader.ReadInt32();
|
||||||
|
|
||||||
ProcessCategory = reader.ReadInt32();
|
Version = reader.ReadInt32();
|
||||||
|
|
||||||
MainThreadStackSize = reader.ReadInt32();
|
MainThreadStackSize = reader.ReadInt32();
|
||||||
|
|
||||||
|
|
|
@ -45,24 +45,24 @@ namespace Ryujinx.Ui
|
||||||
MenuItem openSaveUserDir = new MenuItem("Open User Save Directory")
|
MenuItem openSaveUserDir = new MenuItem("Open User Save Directory")
|
||||||
{
|
{
|
||||||
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.UserAccountSaveDataSize > 0,
|
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.UserAccountSaveDataSize > 0,
|
||||||
TooltipText = "Open the folder where the User save for the application is loaded"
|
TooltipText = "Open the directory which contains Application's User Saves."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem openSaveDeviceDir = new MenuItem("Open Device Save Directory")
|
MenuItem openSaveDeviceDir = new MenuItem("Open Device Save Directory")
|
||||||
{
|
{
|
||||||
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.DeviceSaveDataSize > 0,
|
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.DeviceSaveDataSize > 0,
|
||||||
TooltipText = "Open the folder where the Device save for the application is loaded"
|
TooltipText = "Open the directory which contains Application's Device Saves."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem openSaveBcatDir = new MenuItem("Open BCAT Save Directory")
|
MenuItem openSaveBcatDir = new MenuItem("Open BCAT Save Directory")
|
||||||
{
|
{
|
||||||
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.BcatDeliveryCacheStorageSize > 0,
|
Sensitive = !Util.IsEmpty(controlData.ByteSpan) && controlData.Value.BcatDeliveryCacheStorageSize > 0,
|
||||||
TooltipText = "Open the folder where the BCAT save for the application is loaded"
|
TooltipText = "Open the directory which contains Application's BCAT Saves."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem manageTitleUpdates = new MenuItem("Manage Title Updates")
|
MenuItem manageTitleUpdates = new MenuItem("Manage Title Updates")
|
||||||
{
|
{
|
||||||
TooltipText = "Open the title update management window"
|
TooltipText = "Open the Title Update management window"
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem manageDlc = new MenuItem("Manage DLC")
|
MenuItem manageDlc = new MenuItem("Manage DLC")
|
||||||
|
@ -70,6 +70,11 @@ namespace Ryujinx.Ui
|
||||||
TooltipText = "Open the DLC management window"
|
TooltipText = "Open the DLC management window"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
MenuItem openTitleModDir = new MenuItem("Open Mods Directory")
|
||||||
|
{
|
||||||
|
TooltipText = "Open the directory which contains Application's Mods."
|
||||||
|
};
|
||||||
|
|
||||||
string ext = System.IO.Path.GetExtension(_gameTableStore.GetValue(_rowIter, 9).ToString()).ToLower();
|
string ext = System.IO.Path.GetExtension(_gameTableStore.GetValue(_rowIter, 9).ToString()).ToLower();
|
||||||
bool hasNca = ext == ".nca" || ext == ".nsp" || ext == ".pfs0" || ext == ".xci";
|
bool hasNca = ext == ".nca" || ext == ".nsp" || ext == ".pfs0" || ext == ".xci";
|
||||||
|
|
||||||
|
@ -78,19 +83,19 @@ namespace Ryujinx.Ui
|
||||||
MenuItem extractRomFs = new MenuItem("RomFS")
|
MenuItem extractRomFs = new MenuItem("RomFS")
|
||||||
{
|
{
|
||||||
Sensitive = hasNca,
|
Sensitive = hasNca,
|
||||||
TooltipText = "Extract the RomFs section present in the main NCA"
|
TooltipText = "Extract the RomFS section from Application's current config (including updates)."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem extractExeFs = new MenuItem("ExeFS")
|
MenuItem extractExeFs = new MenuItem("ExeFS")
|
||||||
{
|
{
|
||||||
Sensitive = hasNca,
|
Sensitive = hasNca,
|
||||||
TooltipText = "Extract the ExeFs section present in the main NCA"
|
TooltipText = "Extract the ExeFS section from Application's current config (including updates)."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem extractLogo = new MenuItem("Logo")
|
MenuItem extractLogo = new MenuItem("Logo")
|
||||||
{
|
{
|
||||||
Sensitive = hasNca,
|
Sensitive = hasNca,
|
||||||
TooltipText = "Extract the Logo section present in the main NCA"
|
TooltipText = "Extract the Logo section from Application's current config (including updates)."
|
||||||
};
|
};
|
||||||
|
|
||||||
Menu extractSubMenu = new Menu();
|
Menu extractSubMenu = new Menu();
|
||||||
|
@ -103,14 +108,14 @@ namespace Ryujinx.Ui
|
||||||
|
|
||||||
MenuItem managePtcMenu = new MenuItem("Cache Management");
|
MenuItem managePtcMenu = new MenuItem("Cache Management");
|
||||||
|
|
||||||
MenuItem purgePtcCache = new MenuItem("Purge the PPTC cache")
|
MenuItem purgePtcCache = new MenuItem("Purge PPTC cache")
|
||||||
{
|
{
|
||||||
TooltipText = "Delete the PPTC cache of the game"
|
TooltipText = "Delete the Application's PPTC cache."
|
||||||
};
|
};
|
||||||
|
|
||||||
MenuItem openPtcDir = new MenuItem("Open the PPTC directory")
|
MenuItem openPtcDir = new MenuItem("Open PPTC directory")
|
||||||
{
|
{
|
||||||
TooltipText = "Open the PPTC directory in the file explorer"
|
TooltipText = "Open the directory which contains Application's PPTC cache."
|
||||||
};
|
};
|
||||||
|
|
||||||
Menu managePtcSubMenu = new Menu();
|
Menu managePtcSubMenu = new Menu();
|
||||||
|
@ -125,6 +130,7 @@ namespace Ryujinx.Ui
|
||||||
openSaveBcatDir.Activated += OpenSaveBcatDir_Clicked;
|
openSaveBcatDir.Activated += OpenSaveBcatDir_Clicked;
|
||||||
manageTitleUpdates.Activated += ManageTitleUpdates_Clicked;
|
manageTitleUpdates.Activated += ManageTitleUpdates_Clicked;
|
||||||
manageDlc.Activated += ManageDlc_Clicked;
|
manageDlc.Activated += ManageDlc_Clicked;
|
||||||
|
openTitleModDir.Activated += OpenTitleModDir_Clicked;
|
||||||
extractRomFs.Activated += ExtractRomFs_Clicked;
|
extractRomFs.Activated += ExtractRomFs_Clicked;
|
||||||
extractExeFs.Activated += ExtractExeFs_Clicked;
|
extractExeFs.Activated += ExtractExeFs_Clicked;
|
||||||
extractLogo.Activated += ExtractLogo_Clicked;
|
extractLogo.Activated += ExtractLogo_Clicked;
|
||||||
|
@ -137,6 +143,7 @@ namespace Ryujinx.Ui
|
||||||
this.Add(new SeparatorMenuItem());
|
this.Add(new SeparatorMenuItem());
|
||||||
this.Add(manageTitleUpdates);
|
this.Add(manageTitleUpdates);
|
||||||
this.Add(manageDlc);
|
this.Add(manageDlc);
|
||||||
|
this.Add(openTitleModDir);
|
||||||
this.Add(new SeparatorMenuItem());
|
this.Add(new SeparatorMenuItem());
|
||||||
this.Add(managePtcMenu);
|
this.Add(managePtcMenu);
|
||||||
this.Add(extractMenu);
|
this.Add(extractMenu);
|
||||||
|
@ -602,6 +609,21 @@ namespace Ryujinx.Ui
|
||||||
dlcWindow.Show();
|
dlcWindow.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenTitleModDir_Clicked(object sender, EventArgs args)
|
||||||
|
{
|
||||||
|
string titleId = _gameTableStore.GetValue(_rowIter, 2).ToString().Split("\n")[1].ToLower();
|
||||||
|
|
||||||
|
var modsBasePath = _virtualFileSystem.GetBaseModsPath();
|
||||||
|
var titleModsPath = _virtualFileSystem.ModLoader.GetTitleDir(modsBasePath, titleId);
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = titleModsPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = "open"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ExtractRomFs_Clicked(object sender, EventArgs args)
|
private void ExtractRomFs_Clicked(object sender, EventArgs args)
|
||||||
{
|
{
|
||||||
ExtractSection(NcaSectionType.Data);
|
ExtractSection(NcaSectionType.Data);
|
||||||
|
|
Loading…
Reference in a new issue