diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
index 6107dd59b..d3bf9e5e1 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
@@ -192,11 +192,15 @@ public final class SettingsFragmentPresenter {
         Setting language = systemSection.getSetting(SettingsFile.KEY_LANGUAGE);
         Setting systemClock = systemSection.getSetting(SettingsFile.KEY_INIT_CLOCK);
         Setting dateTime = systemSection.getSetting(SettingsFile.KEY_INIT_TIME);
+        Setting pluginLoader = systemSection.getSetting(SettingsFile.KEY_PLUGIN_LOADER);
+        Setting allowPluginLoader = systemSection.getSetting(SettingsFile.KEY_ALLOW_PLUGIN_LOADER);
 
         sl.add(new SingleChoiceSetting(SettingsFile.KEY_REGION_VALUE, Settings.SECTION_SYSTEM, R.string.emulated_region, 0, R.array.regionNames, R.array.regionValues, -1, region));
         sl.add(new SingleChoiceSetting(SettingsFile.KEY_LANGUAGE, Settings.SECTION_SYSTEM, R.string.emulated_language, 0, R.array.languageNames, R.array.languageValues, 1, language));
         sl.add(new SingleChoiceSetting(SettingsFile.KEY_INIT_CLOCK, Settings.SECTION_SYSTEM, R.string.init_clock, R.string.init_clock_description, R.array.systemClockNames, R.array.systemClockValues, 0, systemClock));
         sl.add(new DateTimeSetting(SettingsFile.KEY_INIT_TIME, Settings.SECTION_SYSTEM, R.string.init_time, R.string.init_time_description, "2000-01-01 00:00:01", dateTime));
+        sl.add(new CheckBoxSetting(SettingsFile.KEY_PLUGIN_LOADER, Settings.SECTION_SYSTEM, R.string.plugin_loader, R.string.plugin_loader_description, false, pluginLoader));
+        sl.add(new CheckBoxSetting(SettingsFile.KEY_ALLOW_PLUGIN_LOADER, Settings.SECTION_SYSTEM, R.string.allow_plugin_loader, R.string.allow_plugin_loader_description, true, allowPluginLoader));
     }
 
     private void addCameraSettings(ArrayList<SettingsItem> sl) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
index 8ae6b70d7..c73f45d2e 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
@@ -78,6 +78,8 @@ public final class SettingsFile {
     public static final String KEY_IS_NEW_3DS = "is_new_3ds";
     public static final String KEY_REGION_VALUE = "region_value";
     public static final String KEY_LANGUAGE = "language";
+    public static final String KEY_PLUGIN_LOADER = "plugin_loader";
+    public static final String KEY_ALLOW_PLUGIN_LOADER = "allow_plugin_loader";
 
     public static final String KEY_INIT_CLOCK = "init_clock";
     public static final String KEY_INIT_TIME = "init_time";
diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp
index e511b438e..768152c77 100644
--- a/src/android/app/src/main/jni/config.cpp
+++ b/src/android/app/src/main/jni/config.cpp
@@ -229,6 +229,10 @@ void Config::ReadValues() {
                 std::chrono::system_clock::from_time_t(std::mktime(&t)).time_since_epoch())
                 .count();
     }
+    Settings::values.plugin_loader_enabled =
+        sdl2_config->GetBoolean("System", "plugin_loader", false);
+    Settings::values.allow_plugin_loader =
+        sdl2_config->GetBoolean("System", "allow_plugin_loader", true);
 
     // Camera
     using namespace Service::CAM;
diff --git a/src/android/app/src/main/jni/default_ini.h b/src/android/app/src/main/jni/default_ini.h
index b07e13a39..2fcbe9292 100644
--- a/src/android/app/src/main/jni/default_ini.h
+++ b/src/android/app/src/main/jni/default_ini.h
@@ -281,6 +281,11 @@ init_clock =
 # Note: 3DS can only handle times later then Jan 1 2000
 init_time =
 
+# Plugin loader state, if enabled plugins will be loaded from the SD card.
+# You can also set if homebrew apps are allowed to enable the plugin loader
+plugin_loader =
+allow_plugin_loader =
+
 [Camera]
 # Which camera engine to use for the right outer camera
 # blank: a dummy camera that always returns black image
diff --git a/src/android/app/src/main/res/values-es/strings.xml b/src/android/app/src/main/res/values-es/strings.xml
index 448d90e87..cd64b9d6c 100644
--- a/src/android/app/src/main/res/values-es/strings.xml
+++ b/src/android/app/src/main/res/values-es/strings.xml
@@ -37,6 +37,10 @@
     <string name="init_time_description">Si el \"Tipo del reloj del sistema\" está en \"Reloj emulado\", ésto cambia la fecha y hora de inicio.</string>
     <string name="emulated_region">Región emulada</string>
     <string name="emulated_language">Idioma emulado</string>
+    <string name="plugin_loader">Activar \"3GX Plugin Loader\"</string>
+    <string name="plugin_loader_description">Carga \"3GX plugins\" de la SD emulada si están disponibles.</string>
+    <string name="allow_plugin_loader">Permiter que apps cambien el estado del \"plugin loader\"</string>
+    <string name="allow_plugin_loader_description">Permite a las aplicaciones homebrew activar el \"plugin loader\" incluso si está desactivado.</string>
 
     <!-- Camera settings strings -->
     <string name="inner_camera">Cámara interior</string>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 8f741a354..b72357eb0 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -51,6 +51,10 @@
     <string name="init_time_description">If the \"System clock type\" setting is set to \"Simulated clock\", this changes the fixed date and time to start at.</string>
     <string name="emulated_region">Emulated region</string>
     <string name="emulated_language">Emulated language</string>
+    <string name="plugin_loader">Enable 3GX Plugin Loader</string>
+    <string name="plugin_loader_description">Loads 3GX plugins from the emulated SD if they are available.</string>
+    <string name="allow_plugin_loader">Allow apps to change plugin loader state</string>
+    <string name="allow_plugin_loader_description">Allow homebrew apps to enable the plugin loader even when it is disabled.</string>
 
     <!-- Camera settings strings -->
     <string name="inner_camera">Inner Camera</string>
diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp
index 7b8323593..212bc351f 100644
--- a/src/citra_qt/configuration/config.cpp
+++ b/src/citra_qt/configuration/config.cpp
@@ -657,6 +657,8 @@ void Config::ReadSystemValues() {
         ReadBasicSetting(Settings::values.init_clock);
         ReadBasicSetting(Settings::values.init_time);
         ReadBasicSetting(Settings::values.init_time_offset);
+        ReadBasicSetting(Settings::values.plugin_loader_enabled);
+        ReadBasicSetting(Settings::values.allow_plugin_loader);
     }
 
     qt_config->endGroup();
@@ -1131,6 +1133,8 @@ void Config::SaveSystemValues() {
         WriteBasicSetting(Settings::values.init_clock);
         WriteBasicSetting(Settings::values.init_time);
         WriteBasicSetting(Settings::values.init_time_offset);
+        WriteBasicSetting(Settings::values.plugin_loader_enabled);
+        WriteBasicSetting(Settings::values.allow_plugin_loader);
     }
 
     qt_config->endGroup();
diff --git a/src/citra_qt/configuration/configure_system.cpp b/src/citra_qt/configuration/configure_system.cpp
index cfde68c2d..e47b0db96 100644
--- a/src/citra_qt/configuration/configure_system.cpp
+++ b/src/citra_qt/configuration/configure_system.cpp
@@ -308,6 +308,8 @@ void ConfigureSystem::SetConfiguration() {
     ui->clock_display_label->setText(
         QStringLiteral("%1%").arg(Settings::values.cpu_clock_percentage.GetValue()));
     ui->toggle_new_3ds->setChecked(Settings::values.is_new_3ds.GetValue());
+    ui->plugin_loader->setChecked(Settings::values.plugin_loader_enabled.GetValue());
+    ui->allow_plugin_loader->setChecked(Settings::values.allow_plugin_loader.GetValue());
 }
 
 void ConfigureSystem::ReadSystemSettings() {
@@ -411,6 +413,10 @@ void ConfigureSystem::ApplyConfiguration() {
         }
 
         Settings::values.init_time_offset = time_offset_days + time_offset_time;
+        Settings::values.is_new_3ds = ui->toggle_new_3ds->isChecked();
+
+        Settings::values.plugin_loader_enabled.SetValue(ui->plugin_loader->isChecked());
+        Settings::values.allow_plugin_loader.SetValue(ui->allow_plugin_loader->isChecked());
     }
 
     ConfigurationShared::ApplyPerGameSetting(
@@ -520,6 +526,13 @@ void ConfigureSystem::SetupPerGameUI() {
     ui->edit_init_time_offset_days->setVisible(false);
     ui->edit_init_time_offset_time->setVisible(false);
     ui->button_regenerate_console_id->setVisible(false);
+    // Apps can change the state of the plugin loader, so plugins load
+    // to a chainloaded app with specific parameters. Don't allow
+    // the plugin loader state to be configured per-game as it may
+    // mess things up.
+    ui->label_plugin_loader->setVisible(false);
+    ui->plugin_loader->setVisible(false);
+    ui->allow_plugin_loader->setVisible(false);
 
     connect(ui->clock_speed_combo, qOverload<int>(&QComboBox::activated), this, [this](int index) {
         ui->slider_clock_speed->setEnabled(index == 1);
diff --git a/src/citra_qt/configuration/configure_system.ui b/src/citra_qt/configuration/configure_system.ui
index b2bb618b3..0dae06b70 100644
--- a/src/citra_qt/configuration/configure_system.ui
+++ b/src/citra_qt/configuration/configure_system.ui
@@ -340,6 +340,27 @@
           </property>
          </widget>
         </item>
+        <item row="13" column="0">
+          <widget class="QLabel" name="label_plugin_loader">
+            <property name="text">
+              <string>3GX Plugin Loader:</string>
+            </property>
+          </widget>
+        </item>
+        <item row="13" column="1">
+          <widget class="QCheckBox" name="plugin_loader">
+            <property name="text">
+              <string>Enable 3GX plugin loader</string>
+            </property>
+          </widget>
+        </item>
+        <item row="14" column="1">
+          <widget class="QCheckBox" name="allow_plugin_loader">
+            <property name="text">
+              <string>Allow games to change plugin loader state</string>
+            </property>
+          </widget>
+        </item>
        </layout>
       </widget>
      </item>
diff --git a/src/common/logging/backend.cpp b/src/common/logging/backend.cpp
index fdc702521..c01c8a376 100644
--- a/src/common/logging/backend.cpp
+++ b/src/common/logging/backend.cpp
@@ -226,6 +226,7 @@ void DebuggerBackend::Write(const Entry& entry) {
     SUB(Service, IR)                                                                               \
     SUB(Service, Y2R)                                                                              \
     SUB(Service, PS)                                                                               \
+    SUB(Service, PLGLDR)                                                                           \
     CLS(HW)                                                                                        \
     SUB(HW, Memory)                                                                                \
     SUB(HW, LCD)                                                                                   \
diff --git a/src/common/logging/log.h b/src/common/logging/log.h
index 59862537b..1109ce7c3 100644
--- a/src/common/logging/log.h
+++ b/src/common/logging/log.h
@@ -93,6 +93,7 @@ enum class Class : ClassType {
     Service_IR,        ///< The IR service
     Service_Y2R,       ///< The Y2R (YUV to RGB conversion) service
     Service_PS,        ///< The PS (Process) service
+    Service_PLGLDR,    ///< The PLGLDR (plugin loader) service
     HW,                ///< Low-level hardware emulation
     HW_Memory,         ///< Memory-map and address translation
     HW_LCD,            ///< LCD register emulation
diff --git a/src/common/settings.cpp b/src/common/settings.cpp
index f6688e1e5..15eda8ab8 100644
--- a/src/common/settings.cpp
+++ b/src/common/settings.cpp
@@ -14,6 +14,7 @@
 #include "core/hle/service/ir/ir_rst.h"
 #include "core/hle/service/ir/ir_user.h"
 #include "core/hle/service/mic_u.h"
+#include "core/hle/service/plgldr/plgldr.h"
 #include "video_core/renderer_base.h"
 #include "video_core/video_core.h"
 
@@ -70,6 +71,9 @@ void Apply() {
 
         Service::MIC::ReloadMic(system);
     }
+
+    Service::PLGLDR::PLG_LDR::SetEnabled(values.plugin_loader_enabled.GetValue());
+    Service::PLGLDR::PLG_LDR::SetAllowGameChangeState(values.allow_plugin_loader.GetValue());
 }
 
 void LogSettings() {
@@ -136,6 +140,8 @@ void LogSettings() {
     }
     log_setting("System_IsNew3ds", values.is_new_3ds.GetValue());
     log_setting("System_RegionValue", values.region_value.GetValue());
+    log_setting("System_PluginLoader", values.plugin_loader_enabled.GetValue());
+    log_setting("System_PluginLoaderAllowed", values.allow_plugin_loader.GetValue());
     log_setting("Debugging_UseGdbstub", values.use_gdbstub.GetValue());
     log_setting("Debugging_GdbstubPort", values.gdbstub_port.GetValue());
 }
diff --git a/src/common/settings.h b/src/common/settings.h
index 38f69a97e..e96779fea 100644
--- a/src/common/settings.h
+++ b/src/common/settings.h
@@ -432,6 +432,8 @@ struct Values {
     Setting<InitClock> init_clock{InitClock::SystemTime, "init_clock"};
     Setting<u64> init_time{946681277ULL, "init_time"};
     Setting<s64> init_time_offset{0, "init_time_offset"};
+    Setting<bool> plugin_loader_enabled{false, "plugin_loader"};
+    Setting<bool> allow_plugin_loader{true, "allow_plugin_loader"};
 
     // Renderer
     Setting<bool> use_gles{false, "use_gles"};
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index fb9b78600..3575b048e 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -80,6 +80,9 @@ add_library(core STATIC
     file_sys/patch.h
     file_sys/path_parser.cpp
     file_sys/path_parser.h
+    file_sys/plugin_3gx.cpp
+    file_sys/plugin_3gx.h
+    file_sys/plugin_3gx_bootloader.h
     file_sys/romfs_reader.cpp
     file_sys/romfs_reader.h
     file_sys/savedata_archive.cpp
@@ -365,6 +368,8 @@ add_library(core STATIC
     hle/service/nwm/uds_connection.h
     hle/service/nwm/uds_data.cpp
     hle/service/nwm/uds_data.h
+    hle/service/plgldr/plgldr.cpp
+    hle/service/plgldr/plgldr.h
     hle/service/pm/pm.cpp
     hle/service/pm/pm.h
     hle/service/pm/pm_app.cpp
diff --git a/src/core/core.cpp b/src/core/core.cpp
index 6d2bd6682..2249a41aa 100644
--- a/src/core/core.cpp
+++ b/src/core/core.cpp
@@ -473,6 +473,10 @@ const Kernel::KernelSystem& System::Kernel() const {
     return *kernel;
 }
 
+bool System::KernelRunning() {
+    return kernel != nullptr;
+}
+
 Timing& System::CoreTiming() {
     return *timing;
 }
diff --git a/src/core/core.h b/src/core/core.h
index bd430a66f..79cbc3f85 100644
--- a/src/core/core.h
+++ b/src/core/core.h
@@ -234,6 +234,9 @@ public:
     /// Gets a const reference to the kernel
     [[nodiscard]] const Kernel::KernelSystem& Kernel() const;
 
+    /// Get kernel is running
+    [[nodiscard]] bool KernelRunning();
+
     /// Gets a reference to the timing system
     [[nodiscard]] Timing& CoreTiming();
 
diff --git a/src/core/file_sys/plugin_3gx.cpp b/src/core/file_sys/plugin_3gx.cpp
new file mode 100644
index 000000000..e59d39090
--- /dev/null
+++ b/src/core/file_sys/plugin_3gx.cpp
@@ -0,0 +1,364 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// Copyright 2022 The Pixellizer Group
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+// associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+#include "core/file_sys/file_backend.h"
+#include "core/file_sys/plugin_3gx.h"
+#include "core/file_sys/plugin_3gx_bootloader.h"
+#include "core/hle/kernel/vm_manager.h"
+#include "core/loader/loader.h"
+
+static std::string ReadTextInfo(FileUtil::IOFile& file, std::size_t offset, std::size_t max_size) {
+    if (max_size > 0x400) { // Limit read string size to 0x400 bytes, just in case
+        return "";
+    }
+    std::vector<char> char_data(max_size);
+
+    const u64 prev_offset = file.Tell();
+    if (!file.Seek(offset, SEEK_SET)) {
+        return "";
+    }
+    if (file.ReadBytes(char_data.data(), max_size) != max_size) {
+        file.Seek(prev_offset, SEEK_SET);
+        return "";
+    }
+    char_data[max_size - 1] = '\0';
+    return std::string(char_data.data());
+}
+
+static bool ReadSection(std::vector<u8>& data_out, FileUtil::IOFile& file, std::size_t offset,
+                        std::size_t size) {
+    if (size > 0x5000000) { // Limit read section size to 5MiB, just in case
+        return false;
+    }
+    data_out.resize(size);
+
+    const u64 prev_offset = file.Tell();
+
+    if (!file.Seek(offset, SEEK_SET)) {
+        return false;
+    }
+    if (file.ReadBytes(data_out.data(), size) != size) {
+        file.Seek(prev_offset, SEEK_SET);
+        return false;
+    }
+    return true;
+}
+
+Loader::ResultStatus FileSys::Plugin3GXLoader::Load(
+    Service::PLGLDR::PLG_LDR::PluginLoaderContext& plg_context, Kernel::Process& process,
+    Kernel::KernelSystem& kernel) {
+    FileUtil::IOFile file(plg_context.plugin_path, "rb");
+    if (!file.IsOpen()) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Not found: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    // Load CIA Header
+    std::vector<u8> header_data(sizeof(_3gx_Header));
+    if (file.ReadBytes(header_data.data(), sizeof(_3gx_Header)) != sizeof(_3gx_Header)) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. File corrupted: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    std::memcpy(&header, header_data.data(), sizeof(_3gx_Header));
+
+    // Check magic value
+    if (std::memcmp(&header.magic, _3GX_magic, 8) != 0) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Outdated or invalid 3GX plugin: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    if (header.infos.flags.compatibility == static_cast<u32>(_3gx_Infos::Compatibility::CONSOLE)) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Not compatible with Citra: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    // Load strings
+    author = ReadTextInfo(file, header.infos.author_msg_offset, header.infos.author_len);
+    title = ReadTextInfo(file, header.infos.title_msg_offset, header.infos.title_len);
+    description =
+        ReadTextInfo(file, header.infos.description_msg_offset, header.infos.description_len);
+    summary = ReadTextInfo(file, header.infos.summary_msg_offset, header.infos.summary_len);
+
+    LOG_INFO(Service_PLGLDR, "Trying to load plugin - Title: {} - Author: {}", title, author);
+
+    // Load compatible TIDs
+    {
+        std::vector<u8> raw_TID_data;
+        if (!ReadSection(raw_TID_data, file, header.targets.title_offsets,
+                         header.targets.count * sizeof(u32))) {
+            return Loader::ResultStatus::Error;
+        }
+        for (u32 i = 0; i < u32(header.targets.count); i++) {
+            compatible_TID.push_back(
+                u32_le(*reinterpret_cast<u32*>(raw_TID_data.data() + i * sizeof(u32))));
+        }
+    }
+
+    if (!compatible_TID.empty() &&
+        std::find(compatible_TID.begin(), compatible_TID.end(),
+                  static_cast<u32>(process.codeset->program_id)) == compatible_TID.end()) {
+        LOG_ERROR(Service_PLGLDR,
+                  "Failed to load 3GX plugin. Not compatible with loaded process: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    // Load exe load func and args
+    if (header.infos.flags.embedded_exe_func.Value() &&
+        header.executable.exe_load_func_offset != 0) {
+        exe_load_func.clear();
+        std::vector<u8> out;
+        for (int i = 0; i < 32; i++) {
+            ReadSection(out, file, header.executable.exe_load_func_offset + i * sizeof(u32),
+                        sizeof(u32));
+            u32 instruction = *reinterpret_cast<u32_le*>(out.data());
+            if (instruction == 0xE320F000) {
+                break;
+            }
+            exe_load_func.push_back(instruction);
+        }
+        memcpy(exe_load_args, header.infos.builtin_load_exe_args,
+               sizeof(_3gx_Infos::builtin_load_exe_args));
+    }
+
+    // Load code sections
+    if (!ReadSection(text_section, file, header.executable.code_offset,
+                     header.executable.code_size) ||
+        !ReadSection(rodata_section, file, header.executable.rodata_offset,
+                     header.executable.rodata_size) ||
+        !ReadSection(data_section, file, header.executable.data_offset,
+                     header.executable.data_size)) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. File corrupted: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    return Map(plg_context, process, kernel);
+}
+
+Loader::ResultStatus FileSys::Plugin3GXLoader::Map(
+    Service::PLGLDR::PLG_LDR::PluginLoaderContext& plg_context, Kernel::Process& process,
+    Kernel::KernelSystem& kernel) {
+
+    // Verify exe load checksum function is available
+    if (exe_load_func.empty() && plg_context.load_exe_func.empty()) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Missing checksum function: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::Error;
+    }
+
+    const std::array<u32, 4> mem_region_sizes = {
+        5 * 1024 * 1024, // 5 MiB
+        2 * 1024 * 1024, // 2 MiB
+        3 * 1024 * 1024, // 3 MiB
+        4 * 1024 * 1024  // 4 MiB
+    };
+
+    // Map memory block. This behaviour mimics how plugins are loaded on 3DS as much as possible.
+    // Calculate the sizes of the different memory regions
+    const u32 block_size = mem_region_sizes[header.infos.flags.memory_region_size.Value()];
+    const u32 exe_size = (sizeof(PluginHeader) + text_section.size() + rodata_section.size() +
+                          data_section.size() + header.executable.bss_size + 0x1000) &
+                         ~0xFFF;
+
+    // Allocate the framebuffer block so that is in the highest FCRAM position possible
+    auto offset_fb =
+        kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)->RLinearAllocate(_3GX_fb_size);
+    if (!offset_fb) {
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Not enough memory: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::ErrorMemoryAllocationFailed;
+    }
+    auto backing_memory_fb = kernel.memory.GetFCRAMRef(*offset_fb);
+    Service::PLGLDR::PLG_LDR::SetPluginFBAddr(Memory::FCRAM_PADDR + *offset_fb);
+    std::fill(backing_memory_fb.GetPtr(), backing_memory_fb.GetPtr() + _3GX_fb_size, 0);
+
+    auto vma_heap_fb = process.vm_manager.MapBackingMemory(
+        _3GX_heap_load_addr, backing_memory_fb, _3GX_fb_size, Kernel::MemoryState::Continuous);
+    ASSERT(vma_heap_fb.Succeeded());
+    process.vm_manager.Reprotect(vma_heap_fb.Unwrap(), Kernel::VMAPermission::ReadWrite);
+
+    // Allocate a block from the end of FCRAM and clear it
+    auto offset = kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)
+                      ->RLinearAllocate(block_size - _3GX_fb_size);
+    if (!offset) {
+        kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)->Free(*offset_fb, _3GX_fb_size);
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Not enough memory: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::ErrorMemoryAllocationFailed;
+    }
+    auto backing_memory = kernel.memory.GetFCRAMRef(*offset);
+    std::fill(backing_memory.GetPtr(), backing_memory.GetPtr() + block_size - _3GX_fb_size, 0);
+
+    // Then we map part of the memory, which contains the executable
+    auto vma = process.vm_manager.MapBackingMemory(_3GX_exe_load_addr, backing_memory, exe_size,
+                                                   Kernel::MemoryState::Continuous);
+    ASSERT(vma.Succeeded());
+    process.vm_manager.Reprotect(vma.Unwrap(), Kernel::VMAPermission::ReadWriteExecute);
+
+    // Write text section
+    kernel.memory.WriteBlock(process, _3GX_exe_load_addr + sizeof(PluginHeader),
+                             text_section.data(), header.executable.code_size);
+    // Write rodata section
+    kernel.memory.WriteBlock(
+        process, _3GX_exe_load_addr + sizeof(PluginHeader) + header.executable.code_size,
+        rodata_section.data(), header.executable.rodata_size);
+    // Write data section
+    kernel.memory.WriteBlock(process,
+                             _3GX_exe_load_addr + sizeof(PluginHeader) +
+                                 header.executable.code_size + header.executable.rodata_size,
+                             data_section.data(), header.executable.data_size);
+    // Prepare plugin header and write it
+    PluginHeader plugin_header = {0};
+    plugin_header.version = header.version;
+    plugin_header.exe_size = exe_size;
+    plugin_header.heap_VA = _3GX_heap_load_addr;
+    plugin_header.heap_size = block_size - exe_size;
+    plg_context.plg_event = _3GX_exe_load_addr - 0x4;
+    plg_context.plg_reply = _3GX_exe_load_addr - 0x8;
+    plugin_header.plgldr_event = plg_context.plg_event;
+    plugin_header.plgldr_reply = plg_context.plg_reply;
+    plugin_header.is_default_plugin = plg_context.is_default_path;
+    if (plg_context.use_user_load_parameters) {
+        memcpy(plugin_header.config, plg_context.user_load_parameters.config,
+               sizeof(PluginHeader::config));
+    }
+    kernel.memory.WriteBlock(process, _3GX_exe_load_addr, &plugin_header, sizeof(PluginHeader));
+
+    // Map plugin heap
+    auto backing_memory_heap = kernel.memory.GetFCRAMRef(*offset + exe_size);
+
+    // Map the rest of the memory at the heap location
+    auto vma_heap = process.vm_manager.MapBackingMemory(
+        _3GX_heap_load_addr + _3GX_fb_size, backing_memory_heap,
+        block_size - exe_size - _3GX_fb_size, Kernel::MemoryState::Continuous);
+    ASSERT(vma_heap.Succeeded());
+    process.vm_manager.Reprotect(vma_heap.Unwrap(), Kernel::VMAPermission::ReadWriteExecute);
+
+    // Allocate a block from the end of FCRAM and clear it
+    auto bootloader_offset = kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)
+                                 ->RLinearAllocate(bootloader_memory_size);
+    if (!bootloader_offset) {
+        kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)->Free(*offset_fb, _3GX_fb_size);
+        kernel.GetMemoryRegion(Kernel::MemoryRegion::SYSTEM)
+            ->Free(*offset, block_size - _3GX_fb_size);
+        LOG_ERROR(Service_PLGLDR, "Failed to load 3GX plugin. Not enough memory: {}",
+                  plg_context.plugin_path);
+        return Loader::ResultStatus::ErrorMemoryAllocationFailed;
+    }
+    const bool use_internal = plg_context.load_exe_func.empty();
+    MapBootloader(
+        process, kernel, *bootloader_offset,
+        (use_internal) ? exe_load_func : plg_context.load_exe_func,
+        (use_internal) ? exe_load_args : plg_context.load_exe_args,
+        header.executable.code_size + header.executable.rodata_size + header.executable.data_size,
+        header.infos.exe_load_checksum,
+        plg_context.use_user_load_parameters ? plg_context.user_load_parameters.no_flash : 0);
+
+    plg_context.plugin_loaded = true;
+    plg_context.use_user_load_parameters = false;
+    return Loader::ResultStatus::Success;
+}
+
+void FileSys::Plugin3GXLoader::MapBootloader(Kernel::Process& process, Kernel::KernelSystem& kernel,
+                                             u32 memory_offset,
+                                             const std::vector<u32>& exe_load_func,
+                                             const u32_le* exe_load_args, u32 checksum_size,
+                                             u32 exe_checksum, bool no_flash) {
+
+    u32_le game_instructions[2];
+    kernel.memory.ReadBlock(process, process.codeset->CodeSegment().addr, game_instructions,
+                            sizeof(u32) * 2);
+
+    std::array<u32_le, g_plugin_loader_bootloader.size() / sizeof(u32)> bootloader;
+    memcpy(bootloader.data(), g_plugin_loader_bootloader.data(), g_plugin_loader_bootloader.size());
+
+    for (auto it = bootloader.begin(); it < bootloader.end(); it++) {
+        switch (static_cast<u32>(*it)) {
+        case 0xDEAD0000: {
+            *it = game_instructions[0];
+        } break;
+        case 0xDEAD0001: {
+            *it = game_instructions[1];
+        } break;
+        case 0xDEAD0002: {
+            *it = process.codeset->CodeSegment().addr;
+        } break;
+        case 0xDEAD0003: {
+            for (u32 i = 0;
+                 i <
+                 sizeof(Service::PLGLDR::PLG_LDR::PluginLoaderContext::load_exe_args) / sizeof(u32);
+                 i++) {
+                bootloader[i + (it - bootloader.begin())] = exe_load_args[i];
+            }
+        } break;
+        case 0xDEAD0004: {
+            *it = _3GX_exe_load_addr + sizeof(PluginHeader);
+        } break;
+        case 0xDEAD0005: {
+            *it = _3GX_exe_load_addr + sizeof(PluginHeader) + checksum_size;
+        } break;
+        case 0xDEAD0006: {
+            *it = exe_checksum;
+        } break;
+        case 0xDEAD0007: {
+            *it = _3GX_exe_load_addr - 0xC;
+        } break;
+        case 0xDEAD0008: {
+            *it = _3GX_exe_load_addr + sizeof(PluginHeader);
+        } break;
+        case 0xDEAD0009: {
+            *it = no_flash ? 1 : 0;
+        } break;
+        case 0xDEAD000A: {
+            for (u32 i = 0; i < exe_load_func.size(); i++) {
+                bootloader[i + (it - bootloader.begin())] = exe_load_func[i];
+            }
+        } break;
+        default:
+            break;
+        }
+    }
+
+    // Map bootloader to the offset provided
+    auto backing_memory = kernel.memory.GetFCRAMRef(memory_offset);
+    std::fill(backing_memory.GetPtr(), backing_memory.GetPtr() + bootloader_memory_size, 0);
+    auto vma = process.vm_manager.MapBackingMemory(_3GX_exe_load_addr - bootloader_memory_size,
+                                                   backing_memory, bootloader_memory_size,
+                                                   Kernel::MemoryState::Continuous);
+    ASSERT(vma.Succeeded());
+    process.vm_manager.Reprotect(vma.Unwrap(), Kernel::VMAPermission::ReadWriteExecute);
+
+    // Write bootloader
+    kernel.memory.WriteBlock(
+        process, _3GX_exe_load_addr - bootloader_memory_size, bootloader.data(),
+        std::min<size_t>(bootloader.size() * sizeof(u32), bootloader_memory_size));
+
+    game_instructions[0] = 0xE51FF004; // ldr pc, [pc, #-4]
+    game_instructions[1] = _3GX_exe_load_addr - bootloader_memory_size;
+    kernel.memory.WriteBlock(process, process.codeset->CodeSegment().addr, game_instructions,
+                             sizeof(u32) * 2);
+}
diff --git a/src/core/file_sys/plugin_3gx.h b/src/core/file_sys/plugin_3gx.h
new file mode 100644
index 000000000..763a0395a
--- /dev/null
+++ b/src/core/file_sys/plugin_3gx.h
@@ -0,0 +1,149 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// Copyright 2022 The Pixellizer Group
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+// associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+#pragma once
+
+#include <core/file_sys/archive_backend.h>
+#include "common/common_types.h"
+#include "common/swap.h"
+#include "core/hle/kernel/process.h"
+#include "core/hle/service/plgldr/plgldr.h"
+
+namespace Loader {
+enum class ResultStatus;
+}
+
+namespace FileUtil {
+class IOFile;
+}
+
+namespace FileSys {
+
+class FileBackend;
+
+class Plugin3GXLoader {
+public:
+    Loader::ResultStatus Load(Service::PLGLDR::PLG_LDR::PluginLoaderContext& plg_context,
+                              Kernel::Process& process, Kernel::KernelSystem& kernel);
+
+    struct PluginHeader {
+        u32_le magic;
+        u32_le version;
+        u32_le heap_VA;
+        u32_le heap_size;
+        u32_le exe_size; // Include sizeof(PluginHeader) + .text + .rodata + .data + .bss (0x1000
+                         // aligned too)
+        u32_le is_default_plugin;
+        u32_le plgldr_event; ///< Used for synchronization, unused in citra
+        u32_le plgldr_reply; ///< Used for synchronization, unused in citra
+        u32_le reserved[24];
+        u32_le config[32];
+    };
+
+    static_assert(sizeof(PluginHeader) == 0x100, "Invalid plugin header size");
+
+    static constexpr const char* _3GX_magic = "3GX$0002";
+    static constexpr u32 _3GX_exe_load_addr = 0x07000000;
+    static constexpr u32 _3GX_heap_load_addr = 0x06000000;
+    static constexpr u32 _3GX_fb_size = 0xA9000;
+
+private:
+    Loader::ResultStatus Map(Service::PLGLDR::PLG_LDR::PluginLoaderContext& plg_context,
+                             Kernel::Process& process, Kernel::KernelSystem& kernel);
+
+    static constexpr size_t bootloader_memory_size = 0x1000;
+    static void MapBootloader(Kernel::Process& process, Kernel::KernelSystem& kernel,
+                              u32 memory_offset, const std::vector<u32>& exe_load_func,
+                              const u32_le* exe_load_args, u32 checksum_size, u32 exe_checksum,
+                              bool no_flash);
+
+    struct _3gx_Infos {
+        enum class Compatibility { CONSOLE = 0, CITRA = 1, CONSOLE_CITRA = 2 };
+        u32_le author_len;
+        u32_le author_msg_offset;
+        u32_le title_len;
+        u32_le title_msg_offset;
+        u32_le summary_len;
+        u32_le summary_msg_offset;
+        u32_le description_len;
+        u32_le description_msg_offset;
+        union {
+            u32_le raw;
+            BitField<0, 1, u32_le> embedded_exe_func;
+            BitField<1, 1, u32_le> embedded_swap_func;
+            BitField<2, 2, u32_le> memory_region_size;
+            BitField<4, 2, u32_le> compatibility;
+        } flags;
+        u32_le exe_load_checksum;
+        u32_le builtin_load_exe_args[4];
+        u32_le builtin_swap_load_args[4];
+    };
+
+    struct _3gx_Targets {
+        u32_le count;
+        u32_le title_offsets;
+    };
+
+    struct _3gx_Symtable {
+        u32_le nb_symbols;
+        u32_le symbols_offset;
+        u32_le name_table_offset;
+    };
+
+    struct _3gx_Executable {
+        u32_le code_offset;
+        u32_le rodata_offset;
+        u32_le data_offset;
+        u32_le code_size;
+        u32_le rodata_size;
+        u32_le data_size;
+        u32_le bss_size;
+        u32_le exe_load_func_offset;  // NOP terminated
+        u32_le swap_save_func_offset; // NOP terminated
+        u32_le swap_load_func_offset; // NOP terminated
+    };
+
+    struct _3gx_Header {
+        u64_le magic;
+        u32_le version;
+        u32_le reserved;
+        _3gx_Infos infos;
+        _3gx_Executable executable;
+        _3gx_Targets targets;
+        _3gx_Symtable symtable;
+    };
+
+    _3gx_Header header;
+
+    std::string author;
+    std::string title;
+    std::string summary;
+    std::string description;
+
+    std::vector<u32> compatible_TID;
+    std::vector<u8> text_section;
+    std::vector<u8> data_section;
+    std::vector<u8> rodata_section;
+
+    std::vector<u32> exe_load_func;
+    u32_le exe_load_args[4];
+};
+} // namespace FileSys
diff --git a/src/core/file_sys/plugin_3gx_bootloader.h b/src/core/file_sys/plugin_3gx_bootloader.h
new file mode 100644
index 000000000..7094c5b12
--- /dev/null
+++ b/src/core/file_sys/plugin_3gx_bootloader.h
@@ -0,0 +1,186 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// Copyright 2022 The Pixellizer Group
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+// associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+#pragma once
+
+// Plugin bootloader payload
+// Compiled with https://shell-storm.org/online/Online-Assembler-and-Disassembler/
+/*
+; Backup registers
+
+    stmfd   sp!, {r0-r12}
+    mrs     r0, cpsr
+    stmfd   sp!, {r0}
+
+; Check plugin validity and exit if invalid (also set a flag)
+
+    adr     r0, g_plgstartendptr
+    ldr     r1, [r0, #4]
+    ldr     r0, [r0]
+    adr     r2, g_plgloadexeargs
+    mov     lr, pc
+    adr     pc, g_loadexefunc
+    adr     r1, g_loadexechecksum
+    ldr     r1, [r1]
+    cmp     r0, r1
+    adr     r0, g_plgldrlaunchstatus
+    ldr     r0, [r0]
+    moveq   r1, #1
+    movne   r1, #0
+    str     r1, [r0]
+    svcne   0x3
+
+; Flash top screen light blue
+
+    adr     r0, g_plgnoflash
+    ldrb    r0, [r0]
+    cmp     r0, #1
+    beq     skipflash
+    ldr     r4, =0x90202204
+    ldr     r5, =0x01FF9933
+    mov     r6, #64
+    flashloop:
+    str     r5, [r4]
+    ldr     r0, =0xFF4B40
+    mov     r1, #0
+    svc     0xA
+    subs    r6, r6, #1
+    bne     flashloop
+    str     r6, [r4]
+    skipflash:
+
+; Set all memory regions to RWX
+
+    ldr     r0, =0xFFFF8001
+    mov     r1, #1
+    svc     0xB3
+
+; Restore instructions at entrypoint
+
+    adr     r0, g_savedGameInstr
+    adr     r1, g_gameentrypoint
+    ldr     r1, [r1]
+    ldr     r2, [r0]
+    str     r2, [r1]
+    ldr     r2, [r0, #4]
+    str     r2, [r1, #4]
+    svc     0x94
+
+; Launch the plugin
+
+    adr     r0, g_savedGameInstr
+    push    {r0}
+    adr     r5, g_plgentrypoint
+    ldr     r5, [r5]
+    blx     r5
+    add     sp, sp, #4
+
+; Restore registers and return to the game
+
+    ldmfd   sp!, {r0}
+    msr     cpsr, r0
+    ldmfd   sp!, {r0-r12}
+    adr     lr, g_gameentrypoint
+    ldr     pc, [lr]
+
+.pool
+
+g_savedGameInstr:
+    .word 0xDEAD0000, 0xDEAD0001
+g_gameentrypoint:
+    .word 0xDEAD0002
+g_plgloadexeargs:
+    .word 0xDEAD0003, 0, 0, 0
+g_plgstartendptr:
+    .word 0xDEAD0004, 0xDEAD0005
+g_loadexechecksum:
+    .word 0xDEAD0006
+g_plgldrlaunchstatus:
+    .word 0xDEAD0007
+g_plgentrypoint:
+    .word 0xDEAD0008
+g_plgnoflash:
+    .word 0xDEAD0009
+g_loadexefunc:
+    .word 0xDEAD000A
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    nop
+    bx lr
+*/
+
+#include <array>
+#include "common/common_types.h"
+
+constexpr std::array<u8, 412> g_plugin_loader_bootloader = {
+    0xff, 0x1f, 0x2d, 0xe9, 0x00, 0x00, 0x0f, 0xe1, 0x01, 0x00, 0x2d, 0xe9, 0xf0, 0x00, 0x8f, 0xe2,
+    0x04, 0x10, 0x90, 0xe5, 0x00, 0x00, 0x90, 0xe5, 0xd4, 0x20, 0x8f, 0xe2, 0x0f, 0xe0, 0xa0, 0xe1,
+    0xf4, 0xf0, 0x8f, 0xe2, 0xe0, 0x10, 0x8f, 0xe2, 0x00, 0x10, 0x91, 0xe5, 0x01, 0x00, 0x50, 0xe1,
+    0xd8, 0x00, 0x8f, 0xe2, 0x00, 0x00, 0x90, 0xe5, 0x01, 0x10, 0xa0, 0x03, 0x00, 0x10, 0xa0, 0x13,
+    0x00, 0x10, 0x80, 0xe5, 0x03, 0x00, 0x00, 0x1f, 0xc8, 0x00, 0x8f, 0xe2, 0x00, 0x00, 0xd0, 0xe5,
+    0x01, 0x00, 0x50, 0xe3, 0x09, 0x00, 0x00, 0x0a, 0x78, 0x40, 0x9f, 0xe5, 0x78, 0x50, 0x9f, 0xe5,
+    0x40, 0x60, 0xa0, 0xe3, 0x00, 0x50, 0x84, 0xe5, 0x70, 0x00, 0x9f, 0xe5, 0x00, 0x10, 0xa0, 0xe3,
+    0x0a, 0x00, 0x00, 0xef, 0x01, 0x60, 0x56, 0xe2, 0xf9, 0xff, 0xff, 0x1a, 0x00, 0x60, 0x84, 0xe5,
+    0x5c, 0x00, 0x9f, 0xe5, 0x01, 0x10, 0xa0, 0xe3, 0xb3, 0x00, 0x00, 0xef, 0x54, 0x00, 0x8f, 0xe2,
+    0x58, 0x10, 0x8f, 0xe2, 0x00, 0x10, 0x91, 0xe5, 0x00, 0x20, 0x90, 0xe5, 0x00, 0x20, 0x81, 0xe5,
+    0x04, 0x20, 0x90, 0xe5, 0x04, 0x20, 0x81, 0xe5, 0x94, 0x00, 0x00, 0xef, 0x34, 0x00, 0x8f, 0xe2,
+    0x04, 0x00, 0x2d, 0xe5, 0x58, 0x50, 0x8f, 0xe2, 0x00, 0x50, 0x95, 0xe5, 0x35, 0xff, 0x2f, 0xe1,
+    0x04, 0xd0, 0x8d, 0xe2, 0x01, 0x00, 0xbd, 0xe8, 0x00, 0xf0, 0x29, 0xe1, 0xff, 0x1f, 0xbd, 0xe8,
+    0x18, 0xe0, 0x8f, 0xe2, 0x00, 0xf0, 0x9e, 0xe5, 0x04, 0x22, 0x20, 0x90, 0x33, 0x99, 0xff, 0x01,
+    0x40, 0x4b, 0xff, 0x00, 0x01, 0x80, 0xff, 0xff, 0x00, 0x00, 0xad, 0xde, 0x01, 0x00, 0xad, 0xde,
+    0x02, 0x00, 0xad, 0xde, 0x03, 0x00, 0xad, 0xde, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0xad, 0xde, 0x05, 0x00, 0xad, 0xde, 0x06, 0x00, 0xad, 0xde,
+    0x07, 0x00, 0xad, 0xde, 0x08, 0x00, 0xad, 0xde, 0x09, 0x00, 0xad, 0xde, 0x0a, 0x00, 0xad, 0xde,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3,
+    0x00, 0xf0, 0x20, 0xe3, 0x00, 0xf0, 0x20, 0xe3, 0x1e, 0xff, 0x2f, 0xe1};
diff --git a/src/core/hle/kernel/kernel.h b/src/core/hle/kernel/kernel.h
index 5d595f674..d37363795 100644
--- a/src/core/hle/kernel/kernel.h
+++ b/src/core/hle/kernel/kernel.h
@@ -82,6 +82,20 @@ enum class MemoryRegion : u16 {
     BASE = 3,
 };
 
+union CoreVersion {
+    CoreVersion(u32 version) : raw(version) {}
+    CoreVersion(u32 major_ver, u32 minor_ver, u32 revision_ver) {
+        revision.Assign(revision_ver);
+        minor.Assign(minor_ver);
+        major.Assign(major_ver);
+    }
+
+    u32 raw;
+    BitField<8, 8, u32> revision;
+    BitField<16, 8, u32> minor;
+    BitField<24, 8, u32> major;
+};
+
 class KernelSystem {
 public:
     explicit KernelSystem(Memory::MemorySystem& memory, Core::Timing& timing,
diff --git a/src/core/hle/kernel/memory.cpp b/src/core/hle/kernel/memory.cpp
index cdef9d996..5ef9de3d4 100644
--- a/src/core/hle/kernel/memory.cpp
+++ b/src/core/hle/kernel/memory.cpp
@@ -245,6 +245,25 @@ std::optional<u32> MemoryRegionInfo::LinearAllocate(u32 size) {
     return std::nullopt;
 }
 
+std::optional<u32> MemoryRegionInfo::RLinearAllocate(u32 size) {
+    ASSERT(!is_locked);
+
+    // Find the first sufficient continuous block from the upper address
+    for (auto iter = free_blocks.rbegin(); iter != free_blocks.rend(); ++iter) {
+        auto interval = *iter;
+        ASSERT(interval.bounds() == boost::icl::interval_bounds::right_open());
+        if (interval.upper() - interval.lower() >= size) {
+            Interval allocated(interval.upper() - size, interval.upper());
+            free_blocks -= allocated;
+            used += size;
+            return allocated.lower();
+        }
+    }
+
+    // No sufficient block found
+    return std::nullopt;
+}
+
 void MemoryRegionInfo::Free(u32 offset, u32 size) {
     if (is_locked) {
         return;
diff --git a/src/core/hle/kernel/memory.h b/src/core/hle/kernel/memory.h
index 9fc5f0a1f..0a7ddd166 100644
--- a/src/core/hle/kernel/memory.h
+++ b/src/core/hle/kernel/memory.h
@@ -60,6 +60,14 @@ struct MemoryRegionInfo {
      */
     std::optional<u32> LinearAllocate(u32 size);
 
+    /**
+     * Allocates memory from the linear heap with only size specified.
+     * @param size size of the memory to allocate.
+     * @returns the address offset to the found block, searching from the end of FCRAM; null if
+     * there is no enough space
+     */
+    std::optional<u32> RLinearAllocate(u32 size);
+
     /**
      * Frees one segment of memory. The memory must have been allocated as heap or linear heap.
      * @param offset the region address offset to the beginning of FCRAM.
diff --git a/src/core/hle/kernel/process.cpp b/src/core/hle/kernel/process.cpp
index a268a0dc2..e2b905e8a 100644
--- a/src/core/hle/kernel/process.cpp
+++ b/src/core/hle/kernel/process.cpp
@@ -12,12 +12,15 @@
 #include "common/common_funcs.h"
 #include "common/logging/log.h"
 #include "common/serialization/boost_vector.hpp"
+#include "core/core.h"
 #include "core/hle/kernel/errors.h"
 #include "core/hle/kernel/memory.h"
 #include "core/hle/kernel/process.h"
 #include "core/hle/kernel/resource_limit.h"
 #include "core/hle/kernel/thread.h"
 #include "core/hle/kernel/vm_manager.h"
+#include "core/hle/service/plgldr/plgldr.h"
+#include "core/loader/loader.h"
 #include "core/memory.h"
 
 SERIALIZE_EXPORT_IMPL(Kernel::Process)
@@ -36,6 +39,7 @@ void Process::serialize(Archive& ar, const unsigned int file_version) {
     ar&(boost::container::vector<AddressMapping, boost::container::dtl::static_storage_allocator<
                                                      AddressMapping, 8, 0, true>>&)address_mappings;
     ar& flags.raw;
+    ar& no_thread_restrictions;
     ar& kernel_version;
     ar& ideal_processor;
     ar& status;
@@ -186,12 +190,24 @@ void Process::Run(s32 main_thread_priority, u32 stack_size) {
         kernel.HandleSpecialMapping(vm_manager, mapping);
     }
 
+    auto plgldr = Service::PLGLDR::GetService(Core::System::GetInstance());
+    if (plgldr) {
+        plgldr->OnProcessRun(*this, kernel);
+    }
+
     status = ProcessStatus::Running;
 
     vm_manager.LogLayout(Log::Level::Debug);
     Kernel::SetupMainThread(kernel, codeset->entrypoint, main_thread_priority, SharedFrom(this));
 }
 
+void Process::Exit() {
+    auto plgldr = Service::PLGLDR::GetService(Core::System::GetInstance());
+    if (plgldr) {
+        plgldr->OnProcessExit(*this, kernel);
+    }
+}
+
 VAddr Process::GetLinearHeapAreaAddress() const {
     // Starting from system version 8.0.0 a new linear heap layout is supported to allow usage of
     // the extra RAM in the n3DS.
@@ -449,7 +465,7 @@ ResultCode Process::Unmap(VAddr target, VAddr source, u32 size, VMAPermission pe
 }
 
 Kernel::Process::Process(KernelSystem& kernel)
-    : Object(kernel), handle_table(kernel), vm_manager(kernel.memory), kernel(kernel) {
+    : Object(kernel), handle_table(kernel), vm_manager(kernel.memory, *this), kernel(kernel) {
     kernel.memory.RegisterPageTable(vm_manager.page_table);
 }
 Kernel::Process::~Process() {
diff --git a/src/core/hle/kernel/process.h b/src/core/hle/kernel/process.h
index 132570cc8..bb4fe6c12 100644
--- a/src/core/hle/kernel/process.h
+++ b/src/core/hle/kernel/process.h
@@ -174,6 +174,7 @@ public:
     /// processes access to specific I/O regions and device memory.
     boost::container::static_vector<AddressMapping, 8> address_mappings;
     ProcessFlags flags;
+    bool no_thread_restrictions = false;
     /// Kernel compatibility version for this process
     u16 kernel_version = 0;
     /// The default CPU for this process, threads are scheduled on this cpu by default.
@@ -200,6 +201,11 @@ public:
      */
     void Run(s32 main_thread_priority, u32 stack_size);
 
+    /**
+     * Called when the process exits by svc
+     */
+    void Exit();
+
     ///////////////////////////////////////////////////////////////////////////////////////////////
     // Memory Management
 
diff --git a/src/core/hle/kernel/svc.cpp b/src/core/hle/kernel/svc.cpp
index ed86f2e49..608d95fea 100644
--- a/src/core/hle/kernel/svc.cpp
+++ b/src/core/hle/kernel/svc.cpp
@@ -38,6 +38,8 @@
 #include "core/hle/kernel/wait_object.h"
 #include "core/hle/lock.h"
 #include "core/hle/result.h"
+#include "core/hle/service/plgldr/plgldr.h"
+#include "core/hle/service/service.h"
 
 namespace Kernel {
 
@@ -65,6 +67,15 @@ struct MemoryInfo {
     u32 state;
 };
 
+/// Values accepted by svcKernelSetState, only the known values are listed
+/// (the behaviour of other values are known, but their purpose is unclear and irrelevant).
+enum class KernelState {
+    /**
+     * Reboots the console
+     */
+    KERNEL_STATE_REBOOT = 7,
+};
+
 struct PageInfo {
     u32 flags;
 };
@@ -85,6 +96,11 @@ enum class SystemInfoType {
      * For the ARM11 NATIVE_FIRM kernel, this is 5, for processes sm, fs, pm, loader, and pxi."
      */
     KERNEL_SPAWNED_PIDS = 26,
+    /**
+     * Check if the current system is a new 3DS. This parameter is not available on real systems,
+     * but can be used by homebrew applications.
+     */
+    NEW_3DS_INFO = 0x10001,
     /**
      * Gets citra related information. This parameter is not available on real systems,
      * but can be used by homebrew applications to get some emulator info.
@@ -92,6 +108,134 @@ enum class SystemInfoType {
     CITRA_INFORMATION = 0x20000,
 };
 
+enum class ProcessInfoType {
+    /**
+     * Returns the amount of private (code, data, regular heap) and shared memory used by the
+     * process + total supervisor-mode stack size + page-rounded size of the external handle table.
+     * This is the amount of physical memory the process is using, minus TLS, main thread stack and
+     * linear memory.
+     */
+    PRIVATE_AND_SHARED_USED_MEMORY = 0,
+
+    /**
+     * Returns the amount of <related unused field> + total supervisor-mode stack size +
+     * page-rounded size of the external handle table.
+     */
+    SUPERVISOR_AND_HANDLE_USED_MEMORY = 1,
+
+    /**
+     * Returns the amount of private (code, data, heap) memory used by the process + total
+     * supervisor-mode stack size + page-rounded size of the external handle table.
+     */
+    PRIVATE_SHARED_SUPERVISOR_HANDLE_USED_MEMORY = 2,
+
+    /**
+     * Returns the amount of <related unused field> + total supervisor-mode stack size +
+     * page-rounded size of the external handle table.
+     */
+    SUPERVISOR_AND_HANDLE_USED_MEMORY2 = 3,
+
+    /**
+     * Returns the amount of handles in use by the process.
+     */
+    USED_HANDLE_COUNT = 4,
+
+    /**
+     * Returns the highest count of handles that have been open at once by the process.
+     */
+    HIGHEST_HANDLE_COUNT = 5,
+
+    /**
+     * Returns *(u32*)(KProcess+0x234) which is always 0.
+     */
+    KPROCESS_0X234 = 6,
+
+    /**
+     * Returns the number of threads of the process.
+     */
+    THREAD_COUNT = 7,
+
+    /**
+     * Returns the maximum number of threads which can be opened by this process (always 0).
+     */
+    MAX_THREAD_AMOUNT = 8,
+
+    /**
+     * Originally this only returned 0xD8E007ED. Now with v11.3 this returns the memregion for the
+     * process: out low u32 = KProcess "Kernel flags from the exheader kernel descriptors" & 0xF00
+     * (memory region flag). High out u32 = 0.
+     */
+    MEMORY_REGION_FLAGS = 19,
+
+    /**
+     * Low u32 = (0x20000000 - <LINEAR virtual-memory base for this process>). That is, the output
+     * value is the value which can be added to LINEAR memory vaddrs for converting to
+     * physical-memory addrs.
+     */
+    LINEAR_BASE_ADDR_OFFSET = 20,
+
+    /**
+     * Returns the VA -> PA conversion offset for the QTM static mem block reserved in the exheader
+     * (0x800000), otherwise 0 (+ error 0xE0E01BF4) if it doesn't exist.
+     */
+    QTM_MEMORY_BLOCK_CONVERSION_OFFSET = 21,
+
+    /**
+     * Returns the base VA of the QTM static mem block reserved in the exheader, otherwise 0 (+
+     * error 0xE0E01BF4) if it doesn't exist.
+     */
+    QTM_MEMORY_ADDRESS = 22,
+
+    /**
+     * Returns the size of the QTM static mem block reserved in the exheader, otherwise 0 (+ error
+     * 0xE0E01BF4) if it doesn't exist.
+     */
+    QTM_MEMORY_SIZE = 23,
+
+    // Custom values used by Luma3DS and 3GX plugins
+
+    /**
+     * Returns the process name.
+     */
+    LUMA_CUSTOM_PROCESS_NAME = 0x10000,
+
+    /**
+     * Returns the process title ID.
+     */
+    LUMA_CUSTOM_PROCESS_TITLE_ID = 0x10001,
+
+    /**
+     * Returns the codeset text size.
+     */
+    LUMA_CUSTOM_TEXT_SIZE = 0x10002,
+
+    /**
+     * Returns the codeset rodata size.
+     */
+    LUMA_CUSTOM_RODATA_SIZE = 0x10003,
+
+    /**
+     * Returns the codeset data size.
+     */
+    LUMA_CUSTOM_DATA_SIZE = 0x10004,
+
+    /**
+     * Returns the codeset text vaddr.
+     */
+    LUMA_CUSTOM_TEXT_ADDR = 0x10005,
+
+    /**
+     * Returns the codeset rodata vaddr.
+     */
+    LUMA_CUSTOM_RODATA_ADDR = 0x10006,
+
+    /**
+     * Returns the codeset data vaddr.
+     */
+    LUMA_CUSTOM_DATA_ADDR = 0x10007,
+
+};
+
 /**
  * Accepted by svcGetSystemInfo param with REGION_MEMORY_USAGE type. Selects a region to query
  * memory usage of.
@@ -121,6 +265,73 @@ enum class SystemInfoCitraInformation {
     BUILD_GIT_DESCRIPTION_PART2 = 41, // Git description (commit) last 7 characters.
 };
 
+/**
+ * Accepted by the custom svcControlProcess.
+ */
+enum class ControlProcessOP {
+    /**
+     * List all handles of the process, varg3 can be either 0 to fetch
+     * all handles, or token of the type to fetch s32 count =
+     * svcControlProcess(handle, PROCESSOP_GET_ALL_HANDLES,
+     * (u32)&outBuf, 0) Returns how many handles were found
+     */
+    PROCESSOP_GET_ALL_HANDLES = 0,
+
+    /**
+     * Set the whole memory of the process with rwx access (in the mmu
+     * table only) svcControlProcess(handle, PROCESSOP_SET_MMU_TO_RWX,
+     * 0, 0)
+     */
+    PROCESSOP_SET_MMU_TO_RWX,
+
+    /**
+     * Get the handle of an event which will be signaled
+     * each time the memory layout of this process changes
+     * svcControlProcess(handle,
+     * PROCESSOP_GET_ON_MEMORY_CHANGE_EVENT,
+     * &eventHandleOut, 0)
+     */
+    PROCESSOP_GET_ON_MEMORY_CHANGE_EVENT,
+
+    /**
+     * Set a flag to be signaled when the process will be exited
+     * svcControlProcess(handle, PROCESSOP_SIGNAL_ON_EXIT, 0, 0)
+     */
+    PROCESSOP_SIGNAL_ON_EXIT,
+
+    /**
+     * Get the physical address of the VAddr within the process
+     * svcControlProcess(handle, PROCESSOP_GET_PA_FROM_VA, (u32)&PAOut,
+     * VAddr)
+     */
+    PROCESSOP_GET_PA_FROM_VA,
+
+    /*
+     * Lock / Unlock the process's threads
+     * svcControlProcess(handle, PROCESSOP_SCHEDULE_THREADS, lock,
+     * threadPredicate) lock: 0 to unlock threads, any other value to
+     * lock threads threadPredicate: can be NULL or a funcptr to a
+     * predicate (typedef bool (*ThreadPredicate)(KThread *thread);)
+     * The predicate must return true to operate on the thread
+     */
+    PROCESSOP_SCHEDULE_THREADS,
+
+    /*
+     * Lock / Unlock the process's threads
+     * svcControlProcess(handle, PROCESSOP_SCHEDULE_THREADS, lock,
+     * tlsmagicexclude) lock: 0 to unlock threads, any other value to
+     * lock threads tlsmagicexclude: do not lock threads with this tls magic
+     * value
+     */
+    PROCESSOP_SCHEDULE_THREADS_WITHOUT_TLS_MAGIC,
+
+    /**
+     * Disable any thread creation restrictions, such as priority value
+     * or allowed cores
+     */
+    PROCESSOP_DISABLE_CREATE_THREAD_RESTRICTIONS,
+};
+
 class SVC : public SVCWrapper<SVC> {
 public:
     SVC(Core::System& system);
@@ -174,6 +385,7 @@ private:
     ResultCode GetThreadId(u32* thread_id, Handle handle);
     ResultCode CreateSemaphore(Handle* out_handle, s32 initial_count, s32 max_count);
     ResultCode ReleaseSemaphore(s32* count, Handle handle, s32 release_count);
+    ResultCode KernelSetState(u32 kernel_state, u32 varg1, u32 varg2);
     ResultCode QueryProcessMemory(MemoryInfo* memory_info, PageInfo* page_info,
                                   Handle process_handle, u32 addr);
     ResultCode QueryMemory(MemoryInfo* memory_info, PageInfo* page_info, u32 addr);
@@ -196,6 +408,14 @@ private:
     ResultCode AcceptSession(Handle* out_server_session, Handle server_port_handle);
     ResultCode GetSystemInfo(s64* out, u32 type, s32 param);
     ResultCode GetProcessInfo(s64* out, Handle process_handle, u32 type);
+    ResultCode GetThreadInfo(s64* out, Handle thread_handle, u32 type);
+    ResultCode InvalidateInstructionCacheRange(u32 addr, u32 size);
+    ResultCode InvalidateEntireInstructionCache();
+    u32 ConvertVaToPa(u32 addr);
+    ResultCode MapProcessMemoryEx(Handle dst_process_handle, u32 dst_address,
+                                  Handle src_process_handle, u32 src_address, u32 size);
+    ResultCode UnmapProcessMemoryEx(Handle process, u32 dst_address, u32 size);
+    ResultCode ControlProcess(Handle process_handle, u32 process_OP, u32 varg2, u32 varg3);
 
     struct FunctionDef {
         using Func = void (SVC::*)();
@@ -205,7 +425,7 @@ private:
         const char* name;
     };
 
-    static const std::array<FunctionDef, 126> SVC_Table;
+    static const std::array<FunctionDef, 180> SVC_Table;
     static const FunctionDef* GetSVCInfo(u32 func_num);
 };
 
@@ -319,6 +539,8 @@ void SVC::ExitProcess() {
         thread->Stop();
     }
 
+    current_process->Exit();
+
     // Kill the current thread
     kernel.GetCurrentThreadManager().GetCurrentThread()->Stop();
 
@@ -920,7 +1142,8 @@ ResultCode SVC::CreateThread(Handle* out_handle, u32 entry_point, u32 arg, VAddr
     std::shared_ptr<Process> current_process = kernel.GetCurrentProcess();
 
     std::shared_ptr<ResourceLimit>& resource_limit = current_process->resource_limit;
-    if (resource_limit->GetMaxResourceValue(ResourceTypes::PRIORITY) > priority) {
+    if (resource_limit->GetMaxResourceValue(ResourceTypes::PRIORITY) > priority &&
+        !current_process->no_thread_restrictions) {
         return ERR_NOT_AUTHORIZED;
     }
 
@@ -945,7 +1168,7 @@ ResultCode SVC::CreateThread(Handle* out_handle, u32 entry_point, u32 arg, VAddr
         // process, exheader kernel-flags bitmask 0x2000 must be set (otherwise error 0xD9001BEA is
         // returned). When processorid==0x3 and the process is not a BASE mem-region process, error
         // 0xD9001BEA is returned. These are the only restriction checks done by the kernel for
-        // processorid.
+        // processorid. If this is implemented, make sure to check process->no_thread_restrictions.
         break;
     default:
         ASSERT_MSG(false, "Unsupported thread processor ID: {}", processor_id);
@@ -1110,6 +1333,22 @@ ResultCode SVC::ReleaseSemaphore(s32* count, Handle handle, s32 release_count) {
     return RESULT_SUCCESS;
 }
 
+/// Sets the kernel state
+ResultCode SVC::KernelSetState(u32 kernel_state, u32 varg1, u32 varg2) {
+    switch (static_cast<KernelState>(kernel_state)) {
+
+    // This triggers a hardware reboot on real console, since this doesn't make sense
+    // on emulator, we shutdown instead.
+    case KernelState::KERNEL_STATE_REBOOT:
+        system.RequestShutdown();
+        break;
+    default:
+        LOG_ERROR(Kernel_SVC, "Unknown KernelSetState state={} varg1={} varg2={}", kernel_state,
+                  varg1, varg2);
+    }
+    return RESULT_SUCCESS;
+}
+
 /// Query process memory
 ResultCode SVC::QueryProcessMemory(MemoryInfo* memory_info, PageInfo* page_info,
                                    Handle process_handle, u32 addr) {
@@ -1434,6 +1673,12 @@ ResultCode SVC::GetSystemInfo(s64* out, u32 type, s32 param) {
     case SystemInfoType::KERNEL_SPAWNED_PIDS:
         *out = 5;
         break;
+    case SystemInfoType::NEW_3DS_INFO:
+        // The actual subtypes are not implemented, homebrew just check
+        // this doesn't return an error in n3ds to know the system type
+        LOG_ERROR(Kernel_SVC, "unimplemented GetSystemInfo type=65537 param={}", param);
+        *out = 0;
+        return (system.GetNumCores() == 4) ? RESULT_SUCCESS : ERR_INVALID_ENUM_VALUE;
     case SystemInfoType::CITRA_INFORMATION:
         switch ((SystemInfoCitraInformation)param) {
         case SystemInfoCitraInformation::IS_CITRA:
@@ -1501,9 +1746,9 @@ ResultCode SVC::GetProcessInfo(s64* out, Handle process_handle, u32 type) {
     if (process == nullptr)
         return ERR_INVALID_HANDLE;
 
-    switch (type) {
-    case 0:
-    case 2:
+    switch (static_cast<ProcessInfoType>(type)) {
+    case ProcessInfoType::PRIVATE_AND_SHARED_USED_MEMORY:
+    case ProcessInfoType::PRIVATE_SHARED_SUPERVISOR_HANDLE_USED_MEMORY:
         // TODO(yuriks): Type 0 returns a slightly higher number than type 2, but I'm not sure
         // what's the difference between them.
         *out = process->memory_used;
@@ -1512,25 +1757,53 @@ ResultCode SVC::GetProcessInfo(s64* out, Handle process_handle, u32 type) {
             return ERR_MISALIGNED_SIZE;
         }
         break;
-    case 1:
-    case 3:
-    case 4:
-    case 5:
-    case 6:
-    case 7:
-    case 8:
+    case ProcessInfoType::SUPERVISOR_AND_HANDLE_USED_MEMORY:
+    case ProcessInfoType::SUPERVISOR_AND_HANDLE_USED_MEMORY2:
+    case ProcessInfoType::USED_HANDLE_COUNT:
+    case ProcessInfoType::HIGHEST_HANDLE_COUNT:
+    case ProcessInfoType::KPROCESS_0X234:
+    case ProcessInfoType::THREAD_COUNT:
+    case ProcessInfoType::MAX_THREAD_AMOUNT:
         // These are valid, but not implemented yet
         LOG_ERROR(Kernel_SVC, "unimplemented GetProcessInfo type={}", type);
         break;
-    case 20:
+    case ProcessInfoType::LINEAR_BASE_ADDR_OFFSET:
         *out = Memory::FCRAM_PADDR - process->GetLinearHeapAreaAddress();
         break;
-    case 21:
-    case 22:
-    case 23:
+    case ProcessInfoType::QTM_MEMORY_BLOCK_CONVERSION_OFFSET:
+    case ProcessInfoType::QTM_MEMORY_ADDRESS:
+    case ProcessInfoType::QTM_MEMORY_SIZE:
         // These return a different error value than higher invalid values
         LOG_ERROR(Kernel_SVC, "unknown GetProcessInfo type={}", type);
         return ERR_NOT_IMPLEMENTED;
+    // Here start the custom ones, taken from Luma3DS for 3GX support
+    case ProcessInfoType::LUMA_CUSTOM_PROCESS_NAME:
+        // Get process name
+        strncpy(reinterpret_cast<char*>(out), process->codeset->GetName().c_str(), 8);
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_PROCESS_TITLE_ID:
+        // Get process TID
+        *out = process->codeset->program_id;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_TEXT_SIZE:
+        *out = process->codeset->CodeSegment().size;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_RODATA_SIZE:
+        *out = process->codeset->RODataSegment().size;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_DATA_SIZE:
+        *out = process->codeset->DataSegment().size;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_TEXT_ADDR:
+        *out = process->codeset->CodeSegment().addr;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_RODATA_ADDR:
+        *out = process->codeset->RODataSegment().addr;
+        break;
+    case ProcessInfoType::LUMA_CUSTOM_DATA_ADDR:
+        *out = process->codeset->DataSegment().addr;
+        break;
+
     default:
         LOG_ERROR(Kernel_SVC, "unknown GetProcessInfo type={}", type);
         return ERR_INVALID_ENUM_VALUE;
@@ -1539,7 +1812,179 @@ ResultCode SVC::GetProcessInfo(s64* out, Handle process_handle, u32 type) {
     return RESULT_SUCCESS;
 }
 
-const std::array<SVC::FunctionDef, 126> SVC::SVC_Table{{
+ResultCode SVC::GetThreadInfo(s64* out, Handle thread_handle, u32 type) {
+    LOG_TRACE(Kernel_SVC, "called thread=0x{:08X} type={}", thread_handle, type);
+
+    std::shared_ptr<Thread> thread =
+        kernel.GetCurrentProcess()->handle_table.Get<Thread>(thread_handle);
+    if (thread == nullptr) {
+        return ERR_INVALID_HANDLE;
+    }
+
+    switch (type) {
+    case 0x10000:
+        *out = static_cast<s64>(thread->GetTLSAddress());
+        break;
+    default:
+        LOG_ERROR(Kernel_SVC, "unknown GetThreadInfo type={}", type);
+        return ERR_INVALID_ENUM_VALUE;
+    }
+
+    return RESULT_SUCCESS;
+}
+
+ResultCode SVC::InvalidateInstructionCacheRange(u32 addr, u32 size) {
+    Core::GetRunningCore().InvalidateCacheRange(addr, size);
+    return RESULT_SUCCESS;
+}
+
+ResultCode SVC::InvalidateEntireInstructionCache() {
+    Core::GetRunningCore().ClearInstructionCache();
+    return RESULT_SUCCESS;
+}
+
+u32 SVC::ConvertVaToPa(u32 addr) {
+    auto vma = kernel.GetCurrentProcess()->vm_manager.FindVMA(addr);
+    if (vma == kernel.GetCurrentProcess()->vm_manager.vma_map.end() ||
+        vma->second.type != VMAType::BackingMemory) {
+        return 0;
+    }
+    return kernel.memory.GetFCRAMOffset(vma->second.backing_memory.GetPtr() + addr -
+                                        vma->second.base) +
+           Memory::FCRAM_PADDR;
+}
+
+ResultCode SVC::MapProcessMemoryEx(Handle dst_process_handle, u32 dst_address,
+                                   Handle src_process_handle, u32 src_address, u32 size) {
+    std::shared_ptr<Process> dst_process =
+        kernel.GetCurrentProcess()->handle_table.Get<Process>(dst_process_handle);
+    std::shared_ptr<Process> src_process =
+        kernel.GetCurrentProcess()->handle_table.Get<Process>(src_process_handle);
+
+    if (dst_process == nullptr || src_process == nullptr) {
+        return ERR_INVALID_HANDLE;
+    }
+
+    if (size & 0xFFF) {
+        size = (size & ~0xFFF) + Memory::CITRA_PAGE_SIZE;
+    }
+
+    // Only linear memory supported
+    auto vma = src_process->vm_manager.FindVMA(src_address);
+    if (vma == src_process->vm_manager.vma_map.end() ||
+        vma->second.type != VMAType::BackingMemory ||
+        vma->second.meminfo_state != MemoryState::Continuous) {
+        return ERR_INVALID_ADDRESS;
+    }
+
+    u32 offset = src_address - vma->second.base;
+    if (offset + size > vma->second.size) {
+        return ERR_INVALID_ADDRESS;
+    }
+
+    auto vma_res = dst_process->vm_manager.MapBackingMemory(
+        dst_address,
+        memory.GetFCRAMRef(vma->second.backing_memory.GetPtr() + offset -
+                           kernel.memory.GetFCRAMPointer(0)),
+        size, Kernel::MemoryState::Continuous);
+
+    if (!vma_res.Succeeded()) {
+        return ERR_INVALID_ADDRESS_STATE;
+    }
+    dst_process->vm_manager.Reprotect(vma_res.Unwrap(), Kernel::VMAPermission::ReadWriteExecute);
+
+    return RESULT_SUCCESS;
+}
+
+ResultCode SVC::UnmapProcessMemoryEx(Handle process, u32 dst_address, u32 size) {
+    std::shared_ptr<Process> dst_process =
+        kernel.GetCurrentProcess()->handle_table.Get<Process>(process);
+
+    if (dst_process == nullptr) {
+        return ERR_INVALID_HANDLE;
+    }
+
+    if (size & 0xFFF) {
+        size = (size & ~0xFFF) + Memory::CITRA_PAGE_SIZE;
+    }
+
+    // Only linear memory supported
+    auto vma = dst_process->vm_manager.FindVMA(dst_address);
+    if (vma == dst_process->vm_manager.vma_map.end() ||
+        vma->second.type != VMAType::BackingMemory ||
+        vma->second.meminfo_state != MemoryState::Continuous) {
+        return ERR_INVALID_ADDRESS;
+    }
+
+    dst_process->vm_manager.UnmapRange(dst_address, size);
+    return RESULT_SUCCESS;
+}
+
+ResultCode SVC::ControlProcess(Handle process_handle, u32 process_OP, u32 varg2, u32 varg3) {
+    std::shared_ptr<Process> process =
+        kernel.GetCurrentProcess()->handle_table.Get<Process>(process_handle);
+
+    if (process == nullptr) {
+        return ERR_INVALID_HANDLE;
+    }
+
+    switch (static_cast<ControlProcessOP>(process_OP)) {
+    case ControlProcessOP::PROCESSOP_SET_MMU_TO_RWX: {
+        for (auto it = process->vm_manager.vma_map.cbegin();
+             it != process->vm_manager.vma_map.cend(); it++) {
+            if (it->second.meminfo_state != MemoryState::Free)
+                process->vm_manager.Reprotect(it, Kernel::VMAPermission::ReadWriteExecute);
+        }
+        return RESULT_SUCCESS;
+    }
+    case ControlProcessOP::PROCESSOP_GET_ON_MEMORY_CHANGE_EVENT: {
+        auto plgldr = Service::PLGLDR::GetService(system);
+        if (!plgldr) {
+            return ERR_NOT_FOUND;
+        }
+
+        ResultVal<Handle> out = plgldr->GetMemoryChangedHandle(kernel);
+        if (out.Failed()) {
+            return out.Code();
+        }
+
+        memory.Write32(varg2, out.Unwrap());
+        return RESULT_SUCCESS;
+    }
+    case ControlProcessOP::PROCESSOP_SCHEDULE_THREADS_WITHOUT_TLS_MAGIC: {
+        for (u32 i = 0; i < system.GetNumCores(); i++) {
+            auto& thread_list = kernel.GetThreadManager(i).GetThreadList();
+            for (auto& thread : thread_list) {
+                if (thread->owner_process.lock() != process) {
+                    continue;
+                }
+                if (memory.Read32(thread.get()->GetTLSAddress()) == varg3) {
+                    continue;
+                }
+                if (thread.get()->thread_id ==
+                    kernel.GetCurrentThreadManager().GetCurrentThread()->thread_id) {
+                    continue;
+                }
+                thread.get()->can_schedule = !varg2;
+            }
+        }
+        return RESULT_SUCCESS;
+    }
+    case ControlProcessOP::PROCESSOP_DISABLE_CREATE_THREAD_RESTRICTIONS: {
+        process->no_thread_restrictions = varg2 == 1;
+        return RESULT_SUCCESS;
+    }
+    case ControlProcessOP::PROCESSOP_GET_ALL_HANDLES:
+    case ControlProcessOP::PROCESSOP_GET_PA_FROM_VA:
+    case ControlProcessOP::PROCESSOP_SIGNAL_ON_EXIT:
+    case ControlProcessOP::PROCESSOP_SCHEDULE_THREADS:
+    default:
+        LOG_ERROR(Kernel_SVC, "Unknown ControlProcessOp type={}", process_OP);
+        return ERR_NOT_IMPLEMENTED;
+    }
+}
+
+const std::array<SVC::FunctionDef, 180> SVC::SVC_Table{{
     {0x00, nullptr, "Unknown"},
     {0x01, &SVC::Wrap<&SVC::ControlMemory>, "ControlMemory"},
     {0x02, &SVC::Wrap<&SVC::QueryMemory>, "QueryMemory"},
@@ -1584,7 +2029,7 @@ const std::array<SVC::FunctionDef, 126> SVC::SVC_Table{{
     {0x29, nullptr, "GetHandleInfo"},
     {0x2A, &SVC::Wrap<&SVC::GetSystemInfo>, "GetSystemInfo"},
     {0x2B, &SVC::Wrap<&SVC::GetProcessInfo>, "GetProcessInfo"},
-    {0x2C, nullptr, "GetThreadInfo"},
+    {0x2C, &SVC::Wrap<&SVC::GetThreadInfo>, "GetThreadInfo"},
     {0x2D, &SVC::Wrap<&SVC::ConnectToPort>, "ConnectToPort"},
     {0x2E, nullptr, "SendSyncRequest1"},
     {0x2F, nullptr, "SendSyncRequest2"},
@@ -1664,8 +2109,63 @@ const std::array<SVC::FunctionDef, 126> SVC::SVC_Table{{
     {0x79, nullptr, "SetResourceLimitValues"},
     {0x7A, nullptr, "AddCodeSegment"},
     {0x7B, nullptr, "Backdoor"},
-    {0x7C, nullptr, "KernelSetState"},
+    {0x7C, &SVC::Wrap<&SVC::KernelSetState>, "KernelSetState"},
     {0x7D, &SVC::Wrap<&SVC::QueryProcessMemory>, "QueryProcessMemory"},
+    // Custom SVCs
+    {0x7E, nullptr, "Unused"},
+    {0x7F, nullptr, "Unused"},
+    {0x80, nullptr, "CustomBackdoor"},
+    {0x81, nullptr, "Unused"},
+    {0x82, nullptr, "Unused"},
+    {0x83, nullptr, "Unused"},
+    {0x84, nullptr, "Unused"},
+    {0x85, nullptr, "Unused"},
+    {0x86, nullptr, "Unused"},
+    {0x87, nullptr, "Unused"},
+    {0x88, nullptr, "Unused"},
+    {0x89, nullptr, "Unused"},
+    {0x8A, nullptr, "Unused"},
+    {0x8B, nullptr, "Unused"},
+    {0x8C, nullptr, "Unused"},
+    {0x8D, nullptr, "Unused"},
+    {0x8E, nullptr, "Unused"},
+    {0x8F, nullptr, "Unused"},
+    {0x90, &SVC::Wrap<&SVC::ConvertVaToPa>, "ConvertVaToPa"},
+    {0x91, nullptr, "FlushDataCacheRange"},
+    {0x92, nullptr, "FlushEntireDataCache"},
+    {0x93, &SVC::Wrap<&SVC::InvalidateInstructionCacheRange>, "InvalidateInstructionCacheRange"},
+    {0x94, &SVC::Wrap<&SVC::InvalidateEntireInstructionCache>, "InvalidateEntireInstructionCache"},
+    {0x95, nullptr, "Unused"},
+    {0x96, nullptr, "Unused"},
+    {0x97, nullptr, "Unused"},
+    {0x98, nullptr, "Unused"},
+    {0x99, nullptr, "Unused"},
+    {0x9A, nullptr, "Unused"},
+    {0x9B, nullptr, "Unused"},
+    {0x9C, nullptr, "Unused"},
+    {0x9D, nullptr, "Unused"},
+    {0x9E, nullptr, "Unused"},
+    {0x9F, nullptr, "Unused"},
+    {0xA0, &SVC::Wrap<&SVC::MapProcessMemoryEx>, "MapProcessMemoryEx"},
+    {0xA1, &SVC::Wrap<&SVC::UnmapProcessMemoryEx>, "UnmapProcessMemoryEx"},
+    {0xA2, nullptr, "ControlMemoryEx"},
+    {0xA3, nullptr, "ControlMemoryUnsafe"},
+    {0xA4, nullptr, "Unused"},
+    {0xA5, nullptr, "Unused"},
+    {0xA6, nullptr, "Unused"},
+    {0xA7, nullptr, "Unused"},
+    {0xA8, nullptr, "Unused"},
+    {0xA9, nullptr, "Unused"},
+    {0xAA, nullptr, "Unused"},
+    {0xAB, nullptr, "Unused"},
+    {0xAC, nullptr, "Unused"},
+    {0xAD, nullptr, "Unused"},
+    {0xAE, nullptr, "Unused"},
+    {0xAF, nullptr, "Unused"},
+    {0xB0, nullptr, "ControlService"},
+    {0xB1, nullptr, "CopyHandle"},
+    {0xB2, nullptr, "TranslateHandle"},
+    {0xB3, &SVC::Wrap<&SVC::ControlProcess>, "ControlProcess"},
 }};
 
 const SVC::FunctionDef* SVC::GetSVCInfo(u32 func_num) {
diff --git a/src/core/hle/kernel/thread.cpp b/src/core/hle/kernel/thread.cpp
index e470a3687..67bcdd4be 100644
--- a/src/core/hle/kernel/thread.cpp
+++ b/src/core/hle/kernel/thread.cpp
@@ -72,8 +72,8 @@ void Thread::Acquire(Thread* thread) {
 }
 
 Thread::Thread(KernelSystem& kernel, u32 core_id)
-    : WaitObject(kernel), context(kernel.GetThreadManager(core_id).NewContext()), core_id(core_id),
-      thread_manager(kernel.GetThreadManager(core_id)) {}
+    : WaitObject(kernel), context(kernel.GetThreadManager(core_id).NewContext()),
+      can_schedule(true), core_id(core_id), thread_manager(kernel.GetThreadManager(core_id)) {}
 Thread::~Thread() {}
 
 Thread* ThreadManager::GetCurrentThread() const {
@@ -164,15 +164,29 @@ Thread* ThreadManager::PopNextReadyThread() {
     Thread* thread = GetCurrentThread();
 
     if (thread && thread->status == ThreadStatus::Running) {
-        // We have to do better than the current thread.
-        // This call returns null when that's not possible.
-        next = ready_queue.pop_first_better(thread->current_priority);
-        if (!next) {
-            // Otherwise just keep going with the current thread
-            next = thread;
-        }
+        do {
+            // We have to do better than the current thread.
+            // This call returns null when that's not possible.
+            next = ready_queue.pop_first_better(thread->current_priority);
+            if (!next) {
+                // Otherwise just keep going with the current thread
+                next = thread;
+                break;
+            } else if (!next->can_schedule)
+                unscheduled_ready_queue.push_back(next);
+        } while (!next->can_schedule);
     } else {
-        next = ready_queue.pop_first();
+        do {
+            next = ready_queue.pop_first();
+            if (next && !next->can_schedule)
+                unscheduled_ready_queue.push_back(next);
+        } while (next && !next->can_schedule);
+    }
+
+    while (!unscheduled_ready_queue.empty()) {
+        auto t = std::move(unscheduled_ready_queue.back());
+        ready_queue.push_back(t->current_priority, t);
+        unscheduled_ready_queue.pop_back();
     }
 
     return next;
diff --git a/src/core/hle/kernel/thread.h b/src/core/hle/kernel/thread.h
index 184318702..8eda6cab7 100644
--- a/src/core/hle/kernel/thread.h
+++ b/src/core/hle/kernel/thread.h
@@ -148,6 +148,7 @@ private:
 
     std::shared_ptr<Thread> current_thread;
     Common::ThreadQueueList<Thread*, ThreadPrioLowest + 1> ready_queue;
+    std::deque<Thread*> unscheduled_ready_queue;
     std::unordered_map<u64, Thread*> wakeup_callback_table;
 
     /// Event type for the thread wake up event
@@ -289,6 +290,7 @@ public:
 
     u32 thread_id;
 
+    bool can_schedule;
     ThreadStatus status;
     VAddr entry_point;
     VAddr stack_top;
diff --git a/src/core/hle/kernel/vm_manager.cpp b/src/core/hle/kernel/vm_manager.cpp
index 7ecc22870..49533bb7e 100644
--- a/src/core/hle/kernel/vm_manager.cpp
+++ b/src/core/hle/kernel/vm_manager.cpp
@@ -5,8 +5,10 @@
 #include <algorithm>
 #include <iterator>
 #include "common/assert.h"
+#include "core/core.h"
 #include "core/hle/kernel/errors.h"
 #include "core/hle/kernel/vm_manager.h"
+#include "core/hle/service/plgldr/plgldr.h"
 #include "core/memory.h"
 #include "core/mmio.h"
 
@@ -37,8 +39,8 @@ bool VirtualMemoryArea::CanBeMergedWith(const VirtualMemoryArea& next) const {
     return true;
 }
 
-VMManager::VMManager(Memory::MemorySystem& memory)
-    : page_table(std::make_shared<Memory::PageTable>()), memory(memory) {
+VMManager::VMManager(Memory::MemorySystem& memory, Kernel::Process& proc)
+    : page_table(std::make_shared<Memory::PageTable>()), memory(memory), process(proc) {
     Reset();
 }
 
@@ -383,6 +385,10 @@ void VMManager::UpdatePageTableForVMA(const VirtualMemoryArea& vma) {
         memory.MapIoRegion(*page_table, vma.base, vma.size, vma.mmio_handler);
         break;
     }
+
+    auto plgldr = Service::PLGLDR::GetService(Core::System::GetInstance());
+    if (plgldr)
+        plgldr->OnMemoryChanged(process, Core::System::GetInstance().Kernel());
 }
 
 ResultVal<std::vector<std::pair<MemoryRef, u32>>> VMManager::GetBackingBlocksForRange(VAddr address,
diff --git a/src/core/hle/kernel/vm_manager.h b/src/core/hle/kernel/vm_manager.h
index a07cc3604..97c601fd6 100644
--- a/src/core/hle/kernel/vm_manager.h
+++ b/src/core/hle/kernel/vm_manager.h
@@ -131,7 +131,7 @@ public:
     std::map<VAddr, VirtualMemoryArea> vma_map;
     using VMAHandle = decltype(vma_map)::const_iterator;
 
-    explicit VMManager(Memory::MemorySystem& memory);
+    explicit VMManager(Memory::MemorySystem& memory, Kernel::Process& proc);
     ~VMManager();
 
     /// Clears the address space map, re-initializing with a single free area.
@@ -254,6 +254,7 @@ private:
     void UpdatePageTableForVMA(const VirtualMemoryArea& vma);
 
     Memory::MemorySystem& memory;
+    Kernel::Process& process;
 
     // When locked, ChangeMemoryState calls will be ignored, other modification calls will hit an
     // assert. VMManager locks itself after deserialization.
diff --git a/src/core/hle/service/gsp/gsp_gpu.cpp b/src/core/hle/service/gsp/gsp_gpu.cpp
index 7cdafa342..f1ae11615 100644
--- a/src/core/hle/service/gsp/gsp_gpu.cpp
+++ b/src/core/hle/service/gsp/gsp_gpu.cpp
@@ -8,6 +8,8 @@
 #include "common/microprofile.h"
 #include "common/swap.h"
 #include "core/core.h"
+#include "core/file_sys/plugin_3gx.h"
+#include "core/hle/ipc.h"
 #include "core/hle/ipc_helpers.h"
 #include "core/hle/kernel/shared_memory.h"
 #include "core/hle/kernel/shared_page.h"
@@ -69,6 +71,9 @@ static PAddr VirtualToPhysicalAddress(VAddr addr) {
     if (addr >= Memory::NEW_LINEAR_HEAP_VADDR && addr <= Memory::NEW_LINEAR_HEAP_VADDR_END) {
         return addr - Memory::NEW_LINEAR_HEAP_VADDR + Memory::FCRAM_PADDR;
     }
+    if (addr >= Memory::PLUGIN_3GX_FB_VADDR && addr <= Memory::PLUGIN_3GX_FB_VADDR_END) {
+        return addr - Memory::PLUGIN_3GX_FB_VADDR + Service::PLGLDR::PLG_LDR::GetPluginFBAddr();
+    }
 
     LOG_ERROR(HW_Memory, "Unknown virtual address @ 0x{:08X}", addr);
     // To help with debugging, set bit on address so that it's obviously invalid.
diff --git a/src/core/hle/service/plgldr/plgldr.cpp b/src/core/hle/service/plgldr/plgldr.cpp
new file mode 100644
index 000000000..497ef32f6
--- /dev/null
+++ b/src/core/hle/service/plgldr/plgldr.cpp
@@ -0,0 +1,278 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// Copyright 2022 The Pixellizer Group
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+// associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+#include <boost/serialization/weak_ptr.hpp>
+#include <fmt/format.h>
+#include "common/archives.h"
+#include "common/logging/log.h"
+#include "common/settings.h"
+#include "core/core.h"
+#include "core/file_sys/plugin_3gx.h"
+#include "core/frontend/mic.h"
+#include "core/hle/ipc.h"
+#include "core/hle/ipc_helpers.h"
+#include "core/hle/kernel/event.h"
+#include "core/hle/kernel/handle_table.h"
+#include "core/hle/kernel/kernel.h"
+#include "core/hle/kernel/shared_memory.h"
+#include "core/hle/service/plgldr/plgldr.h"
+#include "core/loader/loader.h"
+
+SERIALIZE_EXPORT_IMPL(Service::PLGLDR::PLG_LDR)
+
+namespace Service::PLGLDR {
+
+const Kernel::CoreVersion PLG_LDR::plgldr_version = Kernel::CoreVersion(1, 0, 0);
+PLG_LDR::PluginLoaderContext PLG_LDR::plgldr_context;
+bool PLG_LDR::allow_game_change = true;
+PAddr PLG_LDR::plugin_fb_addr = 0;
+
+PLG_LDR::PLG_LDR() : ServiceFramework{"plg:ldr", 1} {
+    static const FunctionInfo functions[] = {
+        {IPC::MakeHeader(1, 0, 2), nullptr, "LoadPlugin"},
+        {IPC::MakeHeader(2, 0, 0), &PLG_LDR::IsEnabled, "IsEnabled"},
+        {IPC::MakeHeader(3, 1, 0), &PLG_LDR::SetEnabled, "SetEnabled"},
+        {IPC::MakeHeader(4, 2, 4), &PLG_LDR::SetLoadSettings, "SetLoadSettings"},
+        {IPC::MakeHeader(5, 1, 8), nullptr, "DisplayMenu"},
+        {IPC::MakeHeader(6, 0, 4), nullptr, "DisplayMessage"},
+        {IPC::MakeHeader(7, 1, 4), &PLG_LDR::DisplayErrorMessage, "DisplayErrorMessage"},
+        {IPC::MakeHeader(8, 0, 0), &PLG_LDR::GetPLGLDRVersion, "GetPLGLDRVersion"},
+        {IPC::MakeHeader(9, 0, 0), &PLG_LDR::GetArbiter, "GetArbiter"},
+        {IPC::MakeHeader(10, 0, 2), &PLG_LDR::GetPluginPath, "GetPluginPath"},
+        {IPC::MakeHeader(11, 1, 0), nullptr, "SetRosalinaMenuBlock"},
+        {IPC::MakeHeader(12, 2, 4), nullptr, "SetSwapParam"},
+        {IPC::MakeHeader(13, 1, 2), nullptr, "SetLoadExeParam"},
+    };
+    RegisterHandlers(functions);
+    plgldr_context.memory_changed_handle = 0;
+    plgldr_context.plugin_loaded = false;
+}
+
+void PLG_LDR::OnProcessRun(Kernel::Process& process, Kernel::KernelSystem& kernel) {
+    if (!plgldr_context.is_enabled || plgldr_context.plugin_loaded) {
+        return;
+    }
+    {
+        // Same check as original plugin loader, plugins are not supported in homebrew apps
+        u32 value1, value2;
+        kernel.memory.ReadBlock(process, process.codeset->CodeSegment().addr, &value1, 4);
+        kernel.memory.ReadBlock(process, process.codeset->CodeSegment().addr + 32, &value2, 4);
+        // Check for "B #0x20" and "MOV R4, LR" instructions
+        bool is_homebrew = u32_le(value1) == 0xEA000006 && u32_le(value2) == 0xE1A0400E;
+        if (is_homebrew) {
+            return;
+        }
+    }
+    FileSys::Plugin3GXLoader plugin_loader;
+    if (plgldr_context.use_user_load_parameters &&
+        plgldr_context.user_load_parameters.low_title_Id ==
+            static_cast<u32>(process.codeset->program_id) &&
+        plgldr_context.user_load_parameters.path[0]) {
+        std::string plugin_file = FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir) +
+                                  std::string(plgldr_context.user_load_parameters.path + 1);
+        plgldr_context.is_default_path = false;
+        plgldr_context.plugin_path = plugin_file;
+        plugin_loader.Load(plgldr_context, process, kernel);
+    } else {
+        const std::string plugin_root =
+            FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir) + "luma/plugins/";
+        const std::string plugin_tid =
+            plugin_root + fmt::format("{:016X}", process.codeset->program_id);
+        FileUtil::FSTEntry entry;
+        FileUtil::ScanDirectoryTree(plugin_tid, entry);
+        for (const auto child : entry.children) {
+            if (!child.isDirectory && child.physicalName.ends_with(".3gx")) {
+                plgldr_context.is_default_path = false;
+                plgldr_context.plugin_path = child.physicalName;
+                if (plugin_loader.Load(plgldr_context, process, kernel) ==
+                    Loader::ResultStatus::Success) {
+                    return;
+                }
+            }
+        }
+
+        const std::string default_path = plugin_root + "default.3gx";
+        if (FileUtil::Exists(default_path)) {
+            plgldr_context.is_default_path = true;
+            plgldr_context.plugin_path = default_path;
+            plugin_loader.Load(plgldr_context, process, kernel);
+        }
+    }
+}
+
+void PLG_LDR::OnProcessExit(Kernel::Process& process, Kernel::KernelSystem& kernel) {
+    if (plgldr_context.plugin_loaded) {
+        u32 status = kernel.memory.Read32(FileSys::Plugin3GXLoader::_3GX_exe_load_addr - 0xC);
+        if (status == 0) {
+            LOG_CRITICAL(Service_PLGLDR, "Failed to launch {}: Checksum failed",
+                         plgldr_context.plugin_path);
+        }
+    }
+}
+
+ResultVal<Kernel::Handle> PLG_LDR::GetMemoryChangedHandle(Kernel::KernelSystem& kernel) {
+    if (plgldr_context.memory_changed_handle)
+        return MakeResult(plgldr_context.memory_changed_handle);
+
+    std::shared_ptr<Kernel::Event> evt = kernel.CreateEvent(
+        Kernel::ResetType::OneShot,
+        fmt::format("event-{:08x}", Core::System::GetInstance().GetRunningCore().GetReg(14)));
+    CASCADE_RESULT(plgldr_context.memory_changed_handle,
+                   kernel.GetCurrentProcess()->handle_table.Create(std::move(evt)));
+
+    return MakeResult(plgldr_context.memory_changed_handle);
+}
+
+void PLG_LDR::OnMemoryChanged(Kernel::Process& process, Kernel::KernelSystem& kernel) {
+    if (!plgldr_context.plugin_loaded || !plgldr_context.memory_changed_handle)
+        return;
+
+    std::shared_ptr<Kernel::Event> evt =
+        kernel.GetCurrentProcess()->handle_table.Get<Kernel::Event>(
+            plgldr_context.memory_changed_handle);
+    if (evt == nullptr)
+        return;
+
+    evt->Signal();
+}
+
+void PLG_LDR::IsEnabled(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 2, 0, 0);
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(2, 0);
+    rb.Push(RESULT_SUCCESS);
+    rb.Push(plgldr_context.is_enabled);
+}
+
+void PLG_LDR::SetEnabled(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 3, 1, 0);
+    bool enabled = rp.Pop<u32>() == 1;
+
+    bool can_change = enabled == plgldr_context.is_enabled || allow_game_change;
+    if (can_change) {
+        plgldr_context.is_enabled = enabled;
+        Settings::values.plugin_loader_enabled.SetValue(enabled);
+    }
+    IPC::RequestBuilder rb = rp.MakeBuilder(1, 0);
+    rb.Push((can_change) ? RESULT_SUCCESS : Kernel::ERR_NOT_AUTHORIZED);
+}
+
+void PLG_LDR::SetLoadSettings(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 4, 2, 4);
+
+    plgldr_context.use_user_load_parameters = true;
+    plgldr_context.user_load_parameters.no_flash = rp.Pop<u32>() == 1;
+    plgldr_context.user_load_parameters.low_title_Id = rp.Pop<u32>();
+
+    auto path = rp.PopMappedBuffer();
+
+    path.Read(
+        plgldr_context.user_load_parameters.path, 0,
+        std::min(sizeof(PluginLoaderContext::PluginLoadParameters::path) - 1, path.GetSize()));
+    plgldr_context.user_load_parameters.path[std::min(
+        sizeof(PluginLoaderContext::PluginLoadParameters::path) - 1, path.GetSize())] = '\0';
+
+    auto config = rp.PopMappedBuffer();
+    config.Read(
+        plgldr_context.user_load_parameters.config, 0,
+        std::min(sizeof(PluginLoaderContext::PluginLoadParameters::config), config.GetSize()));
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(1, 0);
+    rb.Push(RESULT_SUCCESS);
+}
+
+void PLG_LDR::DisplayErrorMessage(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 7, 1, 4);
+    u32 error_code = rp.Pop<u32>();
+    auto title = rp.PopMappedBuffer();
+    auto desc = rp.PopMappedBuffer();
+
+    std::vector<char> title_data(title.GetSize() + 1);
+    std::vector<char> desc_data(desc.GetSize() + 1);
+
+    title.Read(title_data.data(), 0, title.GetSize());
+    title_data[title.GetSize()] = '\0';
+
+    desc.Read(desc_data.data(), 0, desc.GetSize());
+    desc_data[desc.GetSize()] = '\0';
+
+    LOG_ERROR(Service_PLGLDR, "Plugin error - Code: {} - Title: {} - Description: {}", error_code,
+              std::string(title_data.data()), std::string(desc_data.data()));
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(1, 0);
+    rb.Push(RESULT_SUCCESS);
+}
+
+void PLG_LDR::GetPLGLDRVersion(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 8, 0, 0);
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(2, 0);
+    rb.Push(RESULT_SUCCESS);
+    rb.Push(plgldr_version.raw);
+}
+
+void PLG_LDR::GetArbiter(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 9, 0, 0);
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(1, 0);
+    // NOTE: It doesn't make sense to send an arbiter in HLE, as it's used to
+    // signal the plgldr service thread when a event is ready. Instead we just send
+    // an error and the 3GX plugin will take care of it.
+    // (We never send any events anyways)
+    rb.Push(Kernel::ERR_NOT_IMPLEMENTED);
+}
+
+void PLG_LDR::GetPluginPath(Kernel::HLERequestContext& ctx) {
+    IPC::RequestParser rp(ctx, 10, 0, 2);
+    auto path = rp.PopMappedBuffer();
+
+    // Same behaviour as strncpy
+    std::string root_sd = FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir);
+    std::string plugin_path = plgldr_context.plugin_path;
+    auto it = plugin_path.find(root_sd);
+    if (it != plugin_path.npos)
+        plugin_path.erase(it, root_sd.size());
+
+    std::replace(plugin_path.begin(), plugin_path.end(), '\\', '/');
+    if (plugin_path.empty() || plugin_path[0] != '/')
+        plugin_path = "/" + plugin_path;
+
+    path.Write(plugin_path.c_str(), 0, std::min(path.GetSize(), plugin_path.length() + 1));
+
+    IPC::RequestBuilder rb = rp.MakeBuilder(1, 2);
+    rb.Push(RESULT_SUCCESS);
+    rb.PushMappedBuffer(path);
+}
+
+std::shared_ptr<PLG_LDR> GetService(Core::System& system) {
+    if (!system.KernelRunning())
+        return nullptr;
+    auto it = system.Kernel().named_ports.find("plg:ldr");
+    if (it != system.Kernel().named_ports.end())
+        return std::static_pointer_cast<PLG_LDR>(it->second->GetServerPort()->hle_handler);
+    return nullptr;
+}
+
+void InstallInterfaces(Core::System& system) {
+    std::make_shared<PLG_LDR>()->InstallAsNamedPort(system.Kernel());
+}
+
+} // namespace Service::PLGLDR
diff --git a/src/core/hle/service/plgldr/plgldr.h b/src/core/hle/service/plgldr/plgldr.h
new file mode 100644
index 000000000..0780db564
--- /dev/null
+++ b/src/core/hle/service/plgldr/plgldr.h
@@ -0,0 +1,149 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// Copyright 2022 The Pixellizer Group
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+// associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+#pragma once
+
+#include <memory>
+#include <boost/serialization/version.hpp>
+#include "core/hle/service/service.h"
+
+namespace Core {
+class System;
+}
+
+namespace Service::PLGLDR {
+
+class PLG_LDR final : public ServiceFramework<PLG_LDR> {
+public:
+    struct PluginLoaderContext {
+        struct PluginLoadParameters {
+            u8 no_flash = 0;
+            u8 no_IR_Patch = 0;
+            u32_le low_title_Id = 0;
+            char path[256] = {0};
+            u32_le config[32] = {0};
+
+            template <class Archive>
+            void serialize(Archive& ar, const unsigned int) {
+                ar& no_flash;
+                ar& no_IR_Patch;
+                ar& low_title_Id;
+                ar& path;
+                ar& config;
+            }
+            friend class boost::serialization::access;
+        };
+        bool is_enabled = true;
+        bool plugin_loaded = false;
+        bool is_default_path = false;
+        std::string plugin_path = "";
+
+        bool use_user_load_parameters = false;
+        PluginLoadParameters user_load_parameters;
+
+        VAddr plg_event = 0;
+        VAddr plg_reply = 0;
+        Kernel::Handle memory_changed_handle = 0;
+
+        bool is_exe_load_function_set = false;
+        u32 exe_load_checksum = 0;
+
+        std::vector<u32> load_exe_func;
+        u32_le load_exe_args[4] = {0};
+
+        template <class Archive>
+        void serialize(Archive& ar, const unsigned int) {
+            ar& is_enabled;
+            ar& plugin_loaded;
+            ar& is_default_path;
+            ar& plugin_path;
+            ar& use_user_load_parameters;
+            ar& user_load_parameters;
+            ar& plg_event;
+            ar& plg_reply;
+            ar& memory_changed_handle;
+            ar& is_exe_load_function_set;
+            ar& exe_load_checksum;
+            ar& load_exe_func;
+            ar& load_exe_args;
+        }
+        friend class boost::serialization::access;
+    };
+
+    PLG_LDR();
+    ~PLG_LDR() {}
+
+    void OnProcessRun(Kernel::Process& process, Kernel::KernelSystem& kernel);
+    void OnProcessExit(Kernel::Process& process, Kernel::KernelSystem& kernel);
+    ResultVal<Kernel::Handle> GetMemoryChangedHandle(Kernel::KernelSystem& kernel);
+    void OnMemoryChanged(Kernel::Process& process, Kernel::KernelSystem& kernel);
+
+    static void SetEnabled(bool enabled) {
+        plgldr_context.is_enabled = enabled;
+    }
+    static bool GetEnabled() {
+        return plgldr_context.is_enabled;
+    }
+    static void SetAllowGameChangeState(bool allow) {
+        allow_game_change = allow;
+    }
+    static bool GetAllowGameChangeState() {
+        return allow_game_change;
+    }
+    static void SetPluginFBAddr(PAddr addr) {
+        plugin_fb_addr = addr;
+    }
+    static PAddr GetPluginFBAddr() {
+        return plugin_fb_addr;
+    }
+
+private:
+    static const Kernel::CoreVersion plgldr_version;
+
+    static PluginLoaderContext plgldr_context;
+    static PAddr plugin_fb_addr;
+    static bool allow_game_change;
+
+    void IsEnabled(Kernel::HLERequestContext& ctx);
+    void SetEnabled(Kernel::HLERequestContext& ctx);
+    void SetLoadSettings(Kernel::HLERequestContext& ctx);
+    void DisplayErrorMessage(Kernel::HLERequestContext& ctx);
+    void GetPLGLDRVersion(Kernel::HLERequestContext& ctx);
+    void GetArbiter(Kernel::HLERequestContext& ctx);
+    void GetPluginPath(Kernel::HLERequestContext& ctx);
+
+    template <class Archive>
+    void serialize(Archive& ar, const unsigned int) {
+        ar& boost::serialization::base_object<Kernel::SessionRequestHandler>(*this);
+        ar& plgldr_context;
+        ar& plugin_fb_addr;
+        ar& allow_game_change;
+    }
+    friend class boost::serialization::access;
+};
+
+std::shared_ptr<PLG_LDR> GetService(Core::System& system);
+
+void InstallInterfaces(Core::System& system);
+
+} // namespace Service::PLGLDR
+
+BOOST_CLASS_EXPORT_KEY(Service::PLGLDR::PLG_LDR)
diff --git a/src/core/hle/service/service.cpp b/src/core/hle/service/service.cpp
index 94170453f..6edf5083a 100644
--- a/src/core/hle/service/service.cpp
+++ b/src/core/hle/service/service.cpp
@@ -41,6 +41,7 @@
 #include "core/hle/service/nfc/nfc.h"
 #include "core/hle/service/nim/nim.h"
 #include "core/hle/service/nwm/nwm.h"
+#include "core/hle/service/plgldr/plgldr.h"
 #include "core/hle/service/pm/pm.h"
 #include "core/hle/service/ps/ps_ps.h"
 #include "core/hle/service/ptm/ptm.h"
@@ -55,7 +56,7 @@
 
 namespace Service {
 
-const std::array<ServiceModuleInfo, 40> service_module_map{
+const std::array<ServiceModuleInfo, 41> service_module_map{
     {{"FS", 0x00040130'00001102, FS::InstallInterfaces},
      {"PM", 0x00040130'00001202, PM::InstallInterfaces},
      {"LDR", 0x00040130'00003702, LDR::InstallInterfaces},
@@ -94,6 +95,7 @@ const std::array<ServiceModuleInfo, 40> service_module_map{
      {"SOC", 0x00040130'00002E02, SOC::InstallInterfaces},
      {"SSL", 0x00040130'00002F02, SSL::InstallInterfaces},
      {"PS", 0x00040130'00003102, PS::InstallInterfaces},
+     {"PLGLDR", 0x00040130'00006902, PLGLDR::InstallInterfaces},
      // no HLE implementation
      {"CDC", 0x00040130'00001802, nullptr},
      {"GPIO", 0x00040130'00001B02, nullptr},
diff --git a/src/core/hle/service/service.h b/src/core/hle/service/service.h
index 83b0a5fb1..fd783cefc 100644
--- a/src/core/hle/service/service.h
+++ b/src/core/hle/service/service.h
@@ -194,7 +194,7 @@ struct ServiceModuleInfo {
     std::function<void(Core::System&)> init_function;
 };
 
-extern const std::array<ServiceModuleInfo, 40> service_module_map;
+extern const std::array<ServiceModuleInfo, 41> service_module_map;
 
 } // namespace Service
 
diff --git a/src/core/hle/service/sm/srv.cpp b/src/core/hle/service/sm/srv.cpp
index 87a084dbb..aa3a1fbf4 100644
--- a/src/core/hle/service/sm/srv.cpp
+++ b/src/core/hle/service/sm/srv.cpp
@@ -243,10 +243,18 @@ void SRV::PublishToSubscriber(Kernel::HLERequestContext& ctx) {
     u32 notification_id = rp.Pop<u32>();
     u8 flags = rp.Pop<u8>();
 
+    // Handle notification 0x203 in HLE, as this one may be used by homebrew to power off the
+    // console. Normally, this is handled by NS. If notification handling is properly implemented,
+    // this piece of code should be removed, and handled by subscribing from NS instead.
+    if (notification_id == 0x203) {
+        Core::System::GetInstance().RequestShutdown();
+    } else {
+        LOG_WARNING(Service_SRV, "(STUBBED) called, notification_id=0x{:X}, flags={}",
+                    notification_id, flags);
+    }
+
     IPC::RequestBuilder rb = rp.MakeBuilder(1, 0);
     rb.Push(RESULT_SUCCESS);
-    LOG_WARNING(Service_SRV, "(STUBBED) called, notification_id=0x{:X}, flags={}", notification_id,
-                flags);
 }
 
 void SRV::RegisterService(Kernel::HLERequestContext& ctx) {
diff --git a/src/core/memory.cpp b/src/core/memory.cpp
index 8e98ee99c..3b1c6ba93 100644
--- a/src/core/memory.cpp
+++ b/src/core/memory.cpp
@@ -20,6 +20,8 @@
 #include "core/hle/kernel/memory.h"
 #include "core/hle/kernel/process.h"
 #include "core/hle/lock.h"
+#include "core/hle/service/plgldr/plgldr.h"
+#include "core/hw/hw.h"
 #include "core/memory.h"
 #include "video_core/renderer_base.h"
 #include "video_core/video_core.h"
@@ -63,12 +65,16 @@ private:
         if (addr >= NEW_LINEAR_HEAP_VADDR && addr < NEW_LINEAR_HEAP_VADDR_END) {
             return &new_linear_heap[(addr - NEW_LINEAR_HEAP_VADDR) / CITRA_PAGE_SIZE];
         }
+        if (addr >= PLUGIN_3GX_FB_VADDR && addr < PLUGIN_3GX_FB_VADDR_END) {
+            return &plugin_fb[(addr - PLUGIN_3GX_FB_VADDR) / CITRA_PAGE_SIZE];
+        }
         return nullptr;
     }
 
     std::array<bool, VRAM_SIZE / CITRA_PAGE_SIZE> vram{};
     std::array<bool, LINEAR_HEAP_SIZE / CITRA_PAGE_SIZE> linear_heap{};
     std::array<bool, NEW_LINEAR_HEAP_SIZE / CITRA_PAGE_SIZE> new_linear_heap{};
+    std::array<bool, PLUGIN_3GX_FB_SIZE / CITRA_PAGE_SIZE> plugin_fb{};
 
     static_assert(sizeof(bool) == 1);
     friend class boost::serialization::access;
@@ -77,6 +83,7 @@ private:
         ar& vram;
         ar& linear_heap;
         ar& new_linear_heap;
+        ar& plugin_fb;
     }
 };
 
@@ -280,6 +287,10 @@ public:
         if (addr >= VRAM_VADDR && addr < VRAM_VADDR_END) {
             return {vram_mem, addr - VRAM_VADDR};
         }
+        if (addr >= PLUGIN_3GX_FB_VADDR && addr < PLUGIN_3GX_FB_VADDR_END) {
+            return {fcram_mem, addr - PLUGIN_3GX_FB_VADDR +
+                                   Service::PLGLDR::PLG_LDR::GetPluginFBAddr() - FCRAM_PADDR};
+        }
 
         UNREACHABLE();
         return MemoryRef{};
@@ -436,6 +447,22 @@ T MemorySystem::Read(const VAddr vaddr) {
         return value;
     }
 
+    // Custom Luma3ds mapping
+    // Is there a more efficient way to do this?
+    if (vaddr & (1 << 31)) {
+        PAddr paddr = (vaddr & ~(1 << 31));
+        if ((paddr & 0xF0000000) == Memory::FCRAM_PADDR) { // Check FCRAM region
+            T value;
+            std::memcpy(&value, GetFCRAMPointer(paddr - Memory::FCRAM_PADDR), sizeof(T));
+            return value;
+        } else if ((paddr & 0xF0000000) == 0x10000000 &&
+                   paddr >= Memory::IO_AREA_PADDR) { // Check MMIO region
+            T ret;
+            HW::Read<T>(ret, static_cast<VAddr>(paddr) - Memory::IO_AREA_PADDR + 0x1EC00000);
+            return ret;
+        }
+    }
+
     PageType type = impl->current_page_table->attributes[vaddr >> CITRA_PAGE_BITS];
     switch (type) {
     case PageType::Unmapped:
@@ -473,6 +500,20 @@ void MemorySystem::Write(const VAddr vaddr, const T data) {
         return;
     }
 
+    // Custom Luma3ds mapping
+    // Is there a more efficient way to do this?
+    if (vaddr & (1 << 31)) {
+        PAddr paddr = (vaddr & ~(1 << 31));
+        if ((paddr & 0xF0000000) == Memory::FCRAM_PADDR) { // Check FCRAM region
+            std::memcpy(GetFCRAMPointer(paddr - Memory::FCRAM_PADDR), &data, sizeof(T));
+            return;
+        } else if ((paddr & 0xF0000000) == 0x10000000 &&
+                   paddr >= Memory::IO_AREA_PADDR) { // Check MMIO region
+            HW::Write<T>(static_cast<VAddr>(paddr) - Memory::IO_AREA_PADDR + 0x1EC00000, data);
+            return;
+        }
+    }
+
     PageType type = impl->current_page_table->attributes[vaddr >> CITRA_PAGE_BITS];
     switch (type) {
     case PageType::Unmapped:
@@ -657,6 +698,10 @@ static std::vector<VAddr> PhysicalToVirtualAddressForRasterizer(PAddr addr) {
     if (addr >= VRAM_PADDR && addr < VRAM_PADDR_END) {
         return {addr - VRAM_PADDR + VRAM_VADDR};
     }
+    if (addr >= Service::PLGLDR::PLG_LDR::GetPluginFBAddr() &&
+        addr < Service::PLGLDR::PLG_LDR::GetPluginFBAddr() + PLUGIN_3GX_FB_SIZE) {
+        return {addr - Service::PLGLDR::PLG_LDR::GetPluginFBAddr() + PLUGIN_3GX_FB_VADDR};
+    }
     if (addr >= FCRAM_PADDR && addr < FCRAM_PADDR_END) {
         return {addr - FCRAM_PADDR + LINEAR_HEAP_VADDR, addr - FCRAM_PADDR + NEW_LINEAR_HEAP_VADDR};
     }
@@ -796,6 +841,9 @@ void RasterizerFlushVirtualRegion(VAddr start, u32 size, FlushMode mode) {
     CheckRegion(LINEAR_HEAP_VADDR, LINEAR_HEAP_VADDR_END, FCRAM_PADDR);
     CheckRegion(NEW_LINEAR_HEAP_VADDR, NEW_LINEAR_HEAP_VADDR_END, FCRAM_PADDR);
     CheckRegion(VRAM_VADDR, VRAM_VADDR_END, VRAM_PADDR);
+    if (Service::PLGLDR::PLG_LDR::GetPluginFBAddr())
+        CheckRegion(PLUGIN_3GX_FB_VADDR, PLUGIN_3GX_FB_VADDR_END,
+                    Service::PLGLDR::PLG_LDR::GetPluginFBAddr());
 }
 
 u8 MemorySystem::Read8(const VAddr addr) {
diff --git a/src/core/memory.h b/src/core/memory.h
index d77281bcd..eac9fb374 100644
--- a/src/core/memory.h
+++ b/src/core/memory.h
@@ -242,6 +242,11 @@ enum : VAddr {
     NEW_LINEAR_HEAP_VADDR = 0x30000000,
     NEW_LINEAR_HEAP_SIZE = 0x10000000,
     NEW_LINEAR_HEAP_VADDR_END = NEW_LINEAR_HEAP_VADDR + NEW_LINEAR_HEAP_SIZE,
+
+    /// Area where 3GX plugin framebuffers are stored
+    PLUGIN_3GX_FB_VADDR = 0x06000000,
+    PLUGIN_3GX_FB_SIZE = 0x000A9000,
+    PLUGIN_3GX_FB_VADDR_END = PLUGIN_3GX_FB_VADDR + PLUGIN_3GX_FB_SIZE
 };
 
 /**
diff --git a/src/tests/core/memory/vm_manager.cpp b/src/tests/core/memory/vm_manager.cpp
index f8244b357..7553626af 100644
--- a/src/tests/core/memory/vm_manager.cpp
+++ b/src/tests/core/memory/vm_manager.cpp
@@ -4,18 +4,24 @@
 
 #include <vector>
 #include <catch2/catch_test_macros.hpp>
+#include "core/core_timing.h"
 #include "core/hle/kernel/errors.h"
 #include "core/hle/kernel/memory.h"
+#include "core/hle/kernel/process.h"
 #include "core/hle/kernel/vm_manager.h"
 #include "core/memory.h"
 
 TEST_CASE("Memory Basics", "[kernel][memory]") {
     auto mem = std::make_shared<BufferMem>(Memory::CITRA_PAGE_SIZE);
     MemoryRef block{mem};
+    Core::Timing timing(1, 100);
     Memory::MemorySystem memory;
+    Kernel::KernelSystem kernel(
+        memory, timing, [] {}, 0, 1, 0);
+    Kernel::Process process(kernel);
     SECTION("mapping memory") {
         // Because of the PageTable, Kernel::VMManager is too big to be created on the stack.
-        auto manager = std::make_unique<Kernel::VMManager>(memory);
+        auto manager = std::make_unique<Kernel::VMManager>(memory, process);
         auto result =
             manager->MapBackingMemory(Memory::HEAP_VADDR, block, static_cast<u32>(block.GetSize()),
                                       Kernel::MemoryState::Private);
@@ -31,7 +37,7 @@ TEST_CASE("Memory Basics", "[kernel][memory]") {
 
     SECTION("unmapping memory") {
         // Because of the PageTable, Kernel::VMManager is too big to be created on the stack.
-        auto manager = std::make_unique<Kernel::VMManager>(memory);
+        auto manager = std::make_unique<Kernel::VMManager>(memory, process);
         auto result =
             manager->MapBackingMemory(Memory::HEAP_VADDR, block, static_cast<u32>(block.GetSize()),
                                       Kernel::MemoryState::Private);
@@ -49,7 +55,7 @@ TEST_CASE("Memory Basics", "[kernel][memory]") {
 
     SECTION("changing memory permissions") {
         // Because of the PageTable, Kernel::VMManager is too big to be created on the stack.
-        auto manager = std::make_unique<Kernel::VMManager>(memory);
+        auto manager = std::make_unique<Kernel::VMManager>(memory, process);
         auto result =
             manager->MapBackingMemory(Memory::HEAP_VADDR, block, static_cast<u32>(block.GetSize()),
                                       Kernel::MemoryState::Private);
@@ -69,7 +75,7 @@ TEST_CASE("Memory Basics", "[kernel][memory]") {
 
     SECTION("changing memory state") {
         // Because of the PageTable, Kernel::VMManager is too big to be created on the stack.
-        auto manager = std::make_unique<Kernel::VMManager>(memory);
+        auto manager = std::make_unique<Kernel::VMManager>(memory, process);
         auto result =
             manager->MapBackingMemory(Memory::HEAP_VADDR, block, static_cast<u32>(block.GetSize()),
                                       Kernel::MemoryState::Private);