diff --git a/Ryujinx.Common/Logging/LogClass.cs b/Ryujinx.Common/Logging/LogClass.cs
index 2120c9cb04..aad0489136 100644
--- a/Ryujinx.Common/Logging/LogClass.cs
+++ b/Ryujinx.Common/Logging/LogClass.cs
@@ -14,6 +14,7 @@ namespace Ryujinx.Common.Logging
         KernelScheduler,
         KernelSvc,
         Loader,
+        ModLoader,
         Ptc,
         Service,
         ServiceAcc,
diff --git a/Ryujinx.HLE/FileSystem/VirtualFileSystem.cs b/Ryujinx.HLE/FileSystem/VirtualFileSystem.cs
index 347b4fa4dd..5889099533 100644
--- a/Ryujinx.HLE/FileSystem/VirtualFileSystem.cs
+++ b/Ryujinx.HLE/FileSystem/VirtualFileSystem.cs
@@ -17,11 +17,12 @@ namespace Ryujinx.HLE.FileSystem
         public const string NandPath   = "bis";
         public const string SdCardPath = "sdcard";
         public const string SystemPath = "system";
+        public const string ModsPath   = "mods";
 
         public static string SafeNandPath   = Path.Combine(NandPath, "safe");
         public static string SystemNandPath = Path.Combine(NandPath, "system");
         public static string UserNandPath   = Path.Combine(NandPath, "user");
-
+        
         private static bool _isInitialized = false;
 
         public Keyset           KeySet   { get; private set; }
@@ -30,9 +31,12 @@ namespace Ryujinx.HLE.FileSystem
         public EmulatedGameCard GameCard { get; private set; }
         public EmulatedSdCard   SdCard   { get; private set; }
 
+        public ModLoader ModLoader {get; private set;}
+
         private VirtualFileSystem()
         {
             Reload();
+            ModLoader = new ModLoader(); // Should only be created once
         }
 
         public Stream RomFs { get; private set; }
@@ -73,6 +77,14 @@ namespace Ryujinx.HLE.FileSystem
             return fullPath;
         }
 
+        public string GetBaseModsPath()
+        {
+            var baseModsDir = Path.Combine(GetBasePath(), "mods");
+            ModLoader.EnsureBaseDirStructure(baseModsDir);
+
+            return baseModsDir;
+        }
+
         public string GetSdCardPath() => MakeFullPath(SdCardPath);
 
         public string GetNandPath() => MakeFullPath(NandPath);
diff --git a/Ryujinx.HLE/HOS/ApplicationLoader.cs b/Ryujinx.HLE/HOS/ApplicationLoader.cs
index bc7016bda2..994d0f2595 100644
--- a/Ryujinx.HLE/HOS/ApplicationLoader.cs
+++ b/Ryujinx.HLE/HOS/ApplicationLoader.cs
@@ -43,7 +43,8 @@ namespace Ryujinx.HLE.HOS
 
         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)
         {
@@ -52,6 +53,9 @@ namespace Ryujinx.HLE.HOS
             _fileSystem = fileSystem;
 
             ControlData = new BlitStruct<ApplicationControlProperty>(1);
+
+            // Clear Mods cache
+            _fileSystem.ModLoader.Clear();
         }
 
         public void LoadCart(string exeFsDir, string romFsFile = null)
@@ -63,12 +67,14 @@ namespace Ryujinx.HLE.HOS
 
             LocalFileSystem codeFs = new LocalFileSystem(exeFsDir);
 
-            LoadExeFs(codeFs, out _);
+            Npdm metaData = ReadNpdm(codeFs);
 
             if (TitleId != 0)
             {
                 EnsureSaveData(new TitleId(TitleId));
             }
+
+            LoadExeFs(codeFs, metaData);
         }
 
         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
-            LoadExeFs(nsp, out _);
+            LoadExeFs(nsp);
         }
 
         public void LoadNca(string ncaFile)
@@ -272,24 +278,24 @@ namespace Ryujinx.HLE.HOS
             {
                 if (mainNca.CanOpenSection(NcaSectionType.Data))
                 {
-                    dataStorage = mainNca.OpenStorage(NcaSectionType.Data, FsIntegrityCheckLevel);
+                    dataStorage = mainNca.OpenStorage(NcaSectionType.Data, _device.System.FsIntegrityCheckLevel);
                 }
 
                 if (mainNca.CanOpenSection(NcaSectionType.Code))
                 {
-                    codeFs = mainNca.OpenFileSystem(NcaSectionType.Code, FsIntegrityCheckLevel);
+                    codeFs = mainNca.OpenFileSystem(NcaSectionType.Code, _device.System.FsIntegrityCheckLevel);
                 }
             }
             else
             {
                 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))
                 {
-                    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;
             }
 
-            if (dataStorage == null)
-            {
-                Logger.PrintWarning(LogClass.Loader, "No RomFS found in NCA");
-            }
-            else
-            {
-                _fileSystem.SetRomFs(dataStorage.AsStream(FileAccess.Read));
-            }
+            Npdm metaData = ReadNpdm(codeFs);
+
+            _fileSystem.ModLoader.CollectMods(TitleId, _fileSystem.GetBaseModsPath());
 
             if (controlNca != null)
             {
@@ -318,19 +319,52 @@ namespace Ryujinx.HLE.HOS
                 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)
             {
                 EnsureSaveData(new TitleId(TitleId));
             }
 
+            LoadExeFs(codeFs, metaData);
+
             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);
 
@@ -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 (ResultFs.PathNotFound.Includes(result))
+            if (_fileSystem.ModLoader.ReplaceExefsPartition(TitleId, ref codeFs))
             {
-                Logger.PrintWarning(LogClass.Loader, "NPDM file not found, using default values!");
-
-                metaData = GetDefaultNpdm();
-            }
-            else
-            {
-                metaData = new Npdm(npdmFile.AsStream());
+                metaData = null; //TODO: Check if we should retain old npdm
             }
 
-            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)
                     {
@@ -388,25 +416,29 @@ namespace Ryujinx.HLE.HOS
 
                     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);
                 }
             }
 
-            TitleId = metaData.Aci0.TitleId;
-            TitleIs64Bit = metaData.Is64Bit;
+            // ExeFs file replacements
+            bool modified = _fileSystem.ModLoader.ApplyExefsMods(TitleId, nsos);
 
-            LoadNso("rtld");
-            LoadNso("main");
-            LoadNso("subsdk");
-            LoadNso("sdk");
+            var programs = nsos.ToArray();
+
+            modified |= _fileSystem.ModLoader.ApplyNsoPatches(TitleId, programs);
 
             _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)
@@ -420,7 +452,7 @@ namespace Ryujinx.HLE.HOS
             if (isNro)
             {
                 FileStream input = new FileStream(filePath, FileMode.Open);
-                NroExecutable obj = new NroExecutable(input);
+                NroExecutable obj = new NroExecutable(input.AsStorage());
                 executable = obj;
 
                 // homebrew NRO can actually have some data after the actual NRO
@@ -493,7 +525,7 @@ namespace Ryujinx.HLE.HOS
             }
             else
             {
-                executable = new NsoExecutable(new LocalStorage(filePath, FileAccess.Read));
+                executable = new NsoExecutable(new LocalStorage(filePath, FileAccess.Read), Path.GetFileNameWithoutExtension(filePath));
             }
 
             _contentManager.LoadEntries(_device);
diff --git a/Ryujinx.HLE/HOS/Horizon.cs b/Ryujinx.HLE/HOS/Horizon.cs
index c1baae3078..b3af32903e 100644
--- a/Ryujinx.HLE/HOS/Horizon.cs
+++ b/Ryujinx.HLE/HOS/Horizon.cs
@@ -184,11 +184,11 @@ namespace Ryujinx.HLE.HOS
             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()
diff --git a/Ryujinx.HLE/HOS/Kernel/Process/KProcess.cs b/Ryujinx.HLE/HOS/Kernel/Process/KProcess.cs
index 81f8bb6a6c..c67e5c5c2b 100644
--- a/Ryujinx.HLE/HOS/Kernel/Process/KProcess.cs
+++ b/Ryujinx.HLE/HOS/Kernel/Process/KProcess.cs
@@ -66,7 +66,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
         private ulong _imageSize;
         private ulong _mainThreadStackSize;
         private ulong _memoryUsageCapacity;
-        private int   _category;
+        private int   _version;
 
         public KHandleTable HandleTable { get; private set; }
 
@@ -377,7 +377,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
             _creationTimestamp = PerformanceCounter.ElapsedMilliseconds;
 
             MmuFlags    = creationInfo.MmuFlags;
-            _category   = creationInfo.Category;
+            _version   = creationInfo.Version;
             TitleId     = creationInfo.TitleId;
             _entrypoint = creationInfo.CodeAddress;
             _imageSize  = (ulong)creationInfo.CodePagesCount * KMemoryManager.PageSize;
diff --git a/Ryujinx.HLE/HOS/Kernel/Process/ProcessCreationInfo.cs b/Ryujinx.HLE/HOS/Kernel/Process/ProcessCreationInfo.cs
index 7431d7dd25..a582086538 100644
--- a/Ryujinx.HLE/HOS/Kernel/Process/ProcessCreationInfo.cs
+++ b/Ryujinx.HLE/HOS/Kernel/Process/ProcessCreationInfo.cs
@@ -4,7 +4,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
     {
         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 CodeAddress    { get; private set; }
@@ -25,7 +25,7 @@ namespace Ryujinx.HLE.HOS.Kernel.Process
             int    personalMmHeapPagesCount)
         {
             Name                     = name;
-            Category                 = category;
+            Version                  = category;
             TitleId                  = titleId;
             CodeAddress              = codeAddress;
             CodePagesCount           = codePagesCount;
diff --git a/Ryujinx.HLE/HOS/ModLoader.cs b/Ryujinx.HLE/HOS/ModLoader.cs
new file mode 100644
index 0000000000..654d0cbe3e
--- /dev/null
+++ b/Ryujinx.HLE/HOS/ModLoader.cs
@@ -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;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Ryujinx.HLE/HOS/ProgramLoader.cs b/Ryujinx.HLE/HOS/ProgramLoader.cs
index cf5b15bac4..231830a366 100644
--- a/Ryujinx.HLE/HOS/ProgramLoader.cs
+++ b/Ryujinx.HLE/HOS/ProgramLoader.cs
@@ -179,7 +179,7 @@ namespace Ryujinx.HLE.HOS
 
             ProcessCreationInfo creationInfo = new ProcessCreationInfo(
                 metaData.TitleName,
-                metaData.ProcessCategory,
+                metaData.Version,
                 metaData.Aci0.TitleId,
                 codeStart,
                 codePagesCount,
diff --git a/Ryujinx.HLE/HOS/Services/Ro/IRoInterface.cs b/Ryujinx.HLE/HOS/Services/Ro/IRoInterface.cs
index 3ff622b656..46374acc45 100644
--- a/Ryujinx.HLE/HOS/Services/Ro/IRoInterface.cs
+++ b/Ryujinx.HLE/HOS/Services/Ro/IRoInterface.cs
@@ -1,4 +1,5 @@
-using Ryujinx.Common;
+using LibHac.FsSystem;
+using Ryujinx.Common;
 using Ryujinx.Cpu;
 using Ryujinx.HLE.HOS.Kernel.Common;
 using Ryujinx.HLE.HOS.Kernel.Memory;
@@ -163,33 +164,36 @@ namespace Ryujinx.HLE.HOS.Services.Ro
 
             stream.Position = 0;
 
-            NroExecutable executable = new NroExecutable(stream, nroAddress, bssAddress);
+            NroExecutable nro = new NroExecutable(stream.AsStorage(), nroAddress, bssAddress);
 
-            // check if everything is page align.
-            if ((executable.Text.Length & 0xFFF) != 0 || (executable.Ro.Length & 0xFFF) != 0 ||
-                (executable.Data.Length & 0xFFF) != 0 || (executable.BssSize & 0xFFF)   != 0)
+            // Check if everything is page align.
+            if ((nro.Text.Length & 0xFFF) != 0 || (nro.Ro.Length & 0xFFF) != 0 ||
+                (nro.Data.Length & 0xFFF) != 0 || (nro.BssSize & 0xFFF)   != 0)
             {
                 return ResultCode.InvalidNro;
             }
 
-            // check if everything is contiguous.
-            if (executable.RoOffset   != executable.TextOffset + executable.Text.Length ||
-                executable.DataOffset != executable.RoOffset   + executable.Ro.Length   ||
-                nroFileSize           != executable.DataOffset + executable.Data.Length)
+            // Check if everything is contiguous.
+            if (nro.RoOffset   != nro.TextOffset + nro.Text.Length ||
+                nro.DataOffset != nro.RoOffset   + nro.Ro.Length   ||
+                nroFileSize           != nro.DataOffset + nro.Data.Length)
             {
                 return ResultCode.InvalidNro;
             }
 
-            // finally check the bss size match.
-            if ((ulong)executable.BssSize != bssSize)
+            // Check the bss size match.
+            if ((ulong)nro.BssSize != bssSize)
             {
                 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(
-                executable,
+                nro,
                 nroHash,
                 nroAddress,
                 nroSize,
diff --git a/Ryujinx.HLE/Loaders/Executables/IExecutable.cs b/Ryujinx.HLE/Loaders/Executables/IExecutable.cs
index 440e8f5fae..76a550df71 100644
--- a/Ryujinx.HLE/Loaders/Executables/IExecutable.cs
+++ b/Ryujinx.HLE/Loaders/Executables/IExecutable.cs
@@ -1,10 +1,13 @@
+using System;
+
 namespace Ryujinx.HLE.Loaders.Executables
 {
     interface IExecutable
     {
-        byte[] Text { get; }
-        byte[] Ro   { get; }
-        byte[] Data { get; }
+        byte[] Program { get; }        
+        Span<byte> Text { get; }
+        Span<byte> Ro   { get; }
+        Span<byte> Data { get; }
 
         int TextOffset { get; }
         int RoOffset   { get; }
diff --git a/Ryujinx.HLE/Loaders/Executables/KipExecutable.cs b/Ryujinx.HLE/Loaders/Executables/KipExecutable.cs
index 0f1309c028..a44b7c486e 100644
--- a/Ryujinx.HLE/Loaders/Executables/KipExecutable.cs
+++ b/Ryujinx.HLE/Loaders/Executables/KipExecutable.cs
@@ -1,18 +1,24 @@
 using LibHac.Fs;
 using LibHac.Loader;
+using System;
 
 namespace Ryujinx.HLE.Loaders.Executables
 {
     class KipExecutable : IExecutable
     {
-        public byte[] Text { get; }
-        public byte[] Ro { get; }
-        public byte[] Data { get; }
+        public byte[] Program { get; }
+        public Span<byte> Text => Program.AsSpan().Slice(TextOffset, TextSize);
+        public Span<byte> Ro   => Program.AsSpan().Slice(RoOffset,   RoSize);
+        public Span<byte> Data => Program.AsSpan().Slice(DataOffset, DataSize);
 
         public int TextOffset { get; }
         public int RoOffset { get; }
         public int DataOffset { get; }
         public int BssOffset { get; }
+
+        public int TextSize { get; }
+        public int RoSize { get; }
+        public int DataSize { get; }
         public int BssSize { get; }
 
         public int[] Capabilities { get; }
@@ -25,7 +31,6 @@ namespace Ryujinx.HLE.Loaders.Executables
         public byte IdealCoreId { get; }
         public int Version { get; }
         public string Name { get; }
-
         public KipExecutable(IStorage inStorage)
         {
             KipReader reader = new KipReader();
@@ -57,20 +62,23 @@ namespace Ryujinx.HLE.Loaders.Executables
                 Capabilities[index] = (int)reader.Capabilities[index];
             }
 
-            Text = DecompressSection(reader, KipReader.SegmentType.Text);
-            Ro = DecompressSection(reader, KipReader.SegmentType.Ro);
-            Data = DecompressSection(reader, KipReader.SegmentType.Data);
+            reader.GetSegmentSize(KipReader.SegmentType.Data, out int uncompressedSize).ThrowIfFailure();
+            Program = new byte[DataOffset + uncompressedSize];
+
+            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();
 
-            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;
         }
     }
 }
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Executables/NroExecutable.cs b/Ryujinx.HLE/Loaders/Executables/NroExecutable.cs
index 4a7f21167a..b7a887b7cb 100644
--- a/Ryujinx.HLE/Loaders/Executables/NroExecutable.cs
+++ b/Ryujinx.HLE/Loaders/Executables/NroExecutable.cs
@@ -1,67 +1,38 @@
-using System.IO;
+using LibHac;
+using LibHac.Fs;
+using System;
 
 namespace Ryujinx.HLE.Loaders.Executables
 {
-    class NroExecutable : IExecutable
+    class NroExecutable : Nro, IExecutable
     {
-        public byte[] Text { get; private set; }
-        public byte[] Ro   { get; private set; }
-        public byte[] Data { get; private set; }
+        public byte[] Program { get; }
+        public Span<byte> Text => Program.AsSpan().Slice(TextOffset, (int)Header.NroSegments[0].Size);
+        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 { get; private set; }
-        public int RoOffset   { get; private set; }
-        public int DataOffset { get; private set; }
-        public int BssSize    { get; private set; }
-        public int FileSize   { get; private set; }
+        public int TextOffset => (int)Header.NroSegments[0].FileOffset;
+        public int RoOffset   => (int)Header.NroSegments[1].FileOffset;
+        public int DataOffset => (int)Header.NroSegments[2].FileOffset;
+        public int BssOffset  => DataOffset + Data.Length;     
+        public int BssSize    => (int)Header.BssSize;
 
-        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 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;
             BssAddress    = bssAddress;
 
-            BinaryReader reader = new BinaryReader(input);
-
-            input.Seek(4, SeekOrigin.Begin);
-
-            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;
+            OpenNroSegment(NroSegmentType.Text, false).Read(0, Text);
+            OpenNroSegment(NroSegmentType.Ro  , false).Read(0, Ro);
+            OpenNroSegment(NroSegmentType.Data, false).Read(0, Data);
         }
     }
 }
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs b/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs
index bbe2c87fe2..96f1df4e86 100644
--- a/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs
+++ b/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs
@@ -1,23 +1,32 @@
+using LibHac.Common;
 using LibHac.Fs;
 using LibHac.FsSystem;
 using LibHac.Loader;
+using System;
 
 namespace Ryujinx.HLE.Loaders.Executables
 {
     class NsoExecutable : IExecutable
     {
-        public byte[] Text { get; }
-        public byte[] Ro { get; }
-        public byte[] Data { get; }
+        public byte[] Program { get; }
+        public Span<byte> Text => Program.AsSpan().Slice(TextOffset, TextSize);
+        public Span<byte> Ro   => Program.AsSpan().Slice(RoOffset,   RoSize);
+        public Span<byte> Data => Program.AsSpan().Slice(DataOffset, DataSize);
 
         public int TextOffset { get; }
         public int RoOffset { get; }
         public int DataOffset { get; }
         public int BssOffset => DataOffset + Data.Length;
 
+        public int TextSize { get; }
+        public int RoSize { get; }
+        public int DataSize { 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();
 
@@ -28,20 +37,26 @@ namespace Ryujinx.HLE.Loaders.Executables
             DataOffset = (int)reader.Header.Segments[2].MemoryOffset;
             BssSize = (int)reader.Header.BssSize;
 
-            Text = DecompressSection(reader, NsoReader.SegmentType.Text);
-            Ro = DecompressSection(reader, NsoReader.SegmentType.Ro);
-            Data = DecompressSection(reader, NsoReader.SegmentType.Data);
+            reader.GetSegmentSize(NsoReader.SegmentType.Data, out uint uncompressedSize).ThrowIfFailure();
+            Program = new byte[DataOffset + uncompressedSize];
+
+            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();
 
-            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;
         }
     }
 }
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Mods/IPSPatcher.cs b/Ryujinx.HLE/Loaders/Mods/IPSPatcher.cs
new file mode 100644
index 0000000000..d104d25aa5
--- /dev/null
+++ b/Ryujinx.HLE/Loaders/Mods/IPSPatcher.cs
@@ -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);
+        }
+    }
+}
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Mods/IPSwitchPatcher.cs b/Ryujinx.HLE/Loaders/Mods/IPSwitchPatcher.cs
new file mode 100644
index 0000000000..d5b0b1be83
--- /dev/null
+++ b/Ryujinx.HLE/Loaders/Mods/IPSwitchPatcher.cs
@@ -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());
+        }
+    }
+}
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Mods/MemPatch.cs b/Ryujinx.HLE/Loaders/Mods/MemPatch.cs
new file mode 100644
index 0000000000..0fc1915947
--- /dev/null
+++ b/Ryujinx.HLE/Loaders/Mods/MemPatch.cs
@@ -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;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Ryujinx.HLE/Loaders/Npdm/Npdm.cs b/Ryujinx.HLE/Loaders/Npdm/Npdm.cs
index 345721c7ac..7f1e2935e3 100644
--- a/Ryujinx.HLE/Loaders/Npdm/Npdm.cs
+++ b/Ryujinx.HLE/Loaders/Npdm/Npdm.cs
@@ -16,7 +16,7 @@ namespace Ryujinx.HLE.Loaders.Npdm
         public byte   MainThreadPriority  { get; private set; }
         public byte   DefaultCpuId        { 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 string TitleName           { get;         set; }
         public byte[] ProductCode         { get; private set; }
@@ -48,7 +48,7 @@ namespace Ryujinx.HLE.Loaders.Npdm
 
             PersonalMmHeapSize = reader.ReadInt32();
 
-            ProcessCategory = reader.ReadInt32();
+            Version = reader.ReadInt32();
 
             MainThreadStackSize = reader.ReadInt32();
 
diff --git a/Ryujinx/Ui/GameTableContextMenu.cs b/Ryujinx/Ui/GameTableContextMenu.cs
index d2d0030542..2efe1d3e85 100644
--- a/Ryujinx/Ui/GameTableContextMenu.cs
+++ b/Ryujinx/Ui/GameTableContextMenu.cs
@@ -45,24 +45,24 @@ namespace Ryujinx.Ui
             MenuItem openSaveUserDir = new MenuItem("Open User Save Directory")
             {
                 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")
             {
                 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")
             {
                 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")
             {
-                TooltipText = "Open the title update management window"
+                TooltipText = "Open the Title Update management window"
             };
 
             MenuItem manageDlc = new MenuItem("Manage DLC")
@@ -70,6 +70,11 @@ namespace Ryujinx.Ui
                 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();
             bool   hasNca = ext == ".nca" || ext == ".nsp" || ext == ".pfs0" || ext == ".xci";
 
@@ -78,19 +83,19 @@ namespace Ryujinx.Ui
             MenuItem extractRomFs = new MenuItem("RomFS")
             {
                 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")
             {
                 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")
             {
                 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();
@@ -103,14 +108,14 @@ namespace Ryujinx.Ui
 
             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();
@@ -125,6 +130,7 @@ namespace Ryujinx.Ui
             openSaveBcatDir.Activated    += OpenSaveBcatDir_Clicked;
             manageTitleUpdates.Activated += ManageTitleUpdates_Clicked;
             manageDlc.Activated          += ManageDlc_Clicked;
+            openTitleModDir.Activated    += OpenTitleModDir_Clicked;
             extractRomFs.Activated       += ExtractRomFs_Clicked;
             extractExeFs.Activated       += ExtractExeFs_Clicked;
             extractLogo.Activated        += ExtractLogo_Clicked;
@@ -137,6 +143,7 @@ namespace Ryujinx.Ui
             this.Add(new SeparatorMenuItem());
             this.Add(manageTitleUpdates);
             this.Add(manageDlc);
+            this.Add(openTitleModDir);
             this.Add(new SeparatorMenuItem());
             this.Add(managePtcMenu);
             this.Add(extractMenu);
@@ -602,6 +609,21 @@ namespace Ryujinx.Ui
             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)
         {
             ExtractSection(NcaSectionType.Data);