V1 Unpatched Switch ESP32-S3 payload injector

  • Thread starter Thread starter AmeliaFox
  • Start date Start date
  • Views Views 5,401
  • Replies Replies 35
  • Likes Likes 7

AmeliaFox

Well-Known Member
Member
Joined
Jan 25, 2026
Messages
193
Reaction score
210
Trophies
0
Age
25
XP
443
Country
United Kingdom
As per the title, I successfully created a payload injector using an esp32-s3 instead of one of those samd21 based chips. So far i've only made the injection code and it injects the payloads I tried properly and boots into hekate.

I've still got wifi stuff to add and a file manager for uploading new payloads as well as some other stuff that I plan on adding. I've not taken a switch apart before so I don't know how much room is inside it, Once I finish the software I was thinking I could use one of these:

https://thepihut.com/products/esp32-s3-mini-development-board-with-adapter-board

Do you think that would fit inside the switch?
 
Last edited by AmeliaFox,
That's the same footprint as the RP2040-Tiny commonly used for Picofly, only 0.35mm thicker. Might be a tight fit under the shield. WiFi might not work from inside the shield, though.

For an internal install, I'd also worry about power consumption, I guess after injection you could power down the ESP32, or put it to deep sleep, until next reset.
 
  • Like
Reactions: AmeliaFox
That's the same footprint as the RP2040-Tiny commonly used for Picofly, only 0.35mm thicker. Might be a tight fit under the shield. WiFi might not work from inside the shield, though.

For an internal install, I'd also worry about power consumption, I guess after injection you could power down the ESP32, or put it to deep sleep, until next reset.
Yep thanks, I've written most of the code now:

Done - file manager, file editor, injection code, button code, led code.
Still do do - finish config page and this is what I was planning to do next for reference...

If the user doesn't press any esp32-s3 buttons during boot, the esp32 just tries to push the payload until it detects apx/rcm mode. Once the payload is pushed the esp32-s3 goes into deep sleep to save power. If the user presses an esp32-s3 button during boot, wifi or AP mode is activated instead and the user can use the web interface to upload new payloads or select from installed payloads, or update the firmware. I'm probably about 85% complete in the code now so should be finished in a day or two.

I've got Arduino compatible code and an esp-idf version done as well for the payload injection. I've tested so far on ESP32-S3 Devkit C1 - usb to switch is pushed via GPIO 19/20. Also tested on a Lolin ESP32-S3 pro. The code should also work on ESP32-S2 because that also supports USB host - but this has less SRAM and can have less features than ESP32-S3. Once the code is completed I plan on getting some ESP32-S3 mini's as these have the USB port already removed and once programmed any firmware updates or payload updates can be done via wifi.

This is the basic working Arduino code - (just for pushing payloads):
Code:
#include <Arduino.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "usb/usb_host.h"
#include "esp_vfs_fat.h"
#include "wear_levelling.h"

#define TAG "RCM_INJECTOR"

#define APX_VID 0x0955
#define APX_PID 0x7321
#define MAX_LENGTH 0x30298
#define RCM_PAYLOAD_ADDR 0x40010000
#define INTERMEZZO_LOCATION 0x4001F000
#define PAYLOAD_LOAD_BLOCK 0x40020000
#define SEND_CHUNK_SIZE 0x1000

// Simple logging macros for Arduino
#define LOG_I(fmt, ...) Serial.printf("[INFO] " fmt "\n", ##__VA_ARGS__)
#define LOG_E(fmt, ...) Serial.printf("[ERROR] " fmt "\n", ##__VA_ARGS__)
#define LOG_W(fmt, ...) Serial.printf("[WARN] " fmt "\n", ##__VA_ARGS__)

static const uint8_t intermezzo_bin[] = {
    0x44, 0x00, 0x9F, 0xE5, 0x01, 0x11, 0xA0, 0xE3, 0x40, 0x20, 0x9F, 0xE5, 0x00, 0x20, 0x42, 0xE0,
    0x08, 0x00, 0x00, 0xEB, 0x01, 0x01, 0xA0, 0xE3, 0x10, 0xFF, 0x2F, 0xE1, 0x00, 0x00, 0xA0, 0xE1,
    0x2C, 0x00, 0x9F, 0xE5, 0x2C, 0x10, 0x9F, 0xE5, 0x02, 0x28, 0xA0, 0xE3, 0x01, 0x00, 0x00, 0xEB,
    0x20, 0x00, 0x9F, 0xE5, 0x10, 0xFF, 0x2F, 0xE1, 0x04, 0x30, 0x90, 0xE4, 0x04, 0x30, 0x81, 0xE4,
    0x04, 0x20, 0x52, 0xE2, 0xFB, 0xFF, 0xFF, 0x1A, 0x1E, 0xFF, 0x2F, 0xE1, 0x20, 0xF0, 0x01, 0x40,
    0x5C, 0xF0, 0x01, 0x40, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x01, 0x40
};

static usb_host_client_handle_t client_hdl = NULL;
static usb_device_handle_t dev_hdl = NULL;
static volatile bool device_connected = false;
static volatile bool injection_done = false;

// Dummy callback - required but we don't rely on it
static void dummy_cb(usb_transfer_t *transfer) { }

static void usb_event_cb(const usb_host_client_event_msg_t *event, void *arg) {
    if (event->event == USB_HOST_CLIENT_EVENT_NEW_DEV) {
        usb_device_handle_t test_hdl;
        esp_err_t err = usb_host_device_open(client_hdl, event->new_dev.address, &test_hdl);
        if (err == ESP_OK) {
            const usb_device_desc_t *dev_desc;
            err = usb_host_get_device_descriptor(test_hdl, &dev_desc);
            if (err == ESP_OK && dev_desc->idVendor == APX_VID && dev_desc->idProduct == APX_PID) {
                LOG_I("*** SWITCH RCM DETECTED ***");
                dev_hdl = test_hdl;
                device_connected = true;
                return;
            }
            usb_host_device_close(client_hdl, test_hdl);
        }
    } else if (event->event == USB_HOST_CLIENT_EVENT_DEV_GONE) {
        device_connected = false;
        dev_hdl = NULL;
    }
}

static esp_err_t init_internal_storage(void) {
    const esp_vfs_fat_mount_config_t mount_config = {
        .format_if_mount_failed = true,
        .max_files = 2,
        .allocation_unit_size = 4096
    };
    wl_handle_t wl_handle;
    esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl("/data", "ffat", &mount_config, &wl_handle);
    if (err != ESP_OK) {
        LOG_E("FATFS mount failed: %s", esp_err_to_name(err));
        return err;
    }
    LOG_I("FATFS mounted at /data");
    return ESP_OK;
}

// CRITICAL: Poll for completion by checking actual_num_bytes and status
static bool wait_for_transfer(usb_transfer_t *xfer, uint32_t timeout_ms, size_t expected_bytes) {
    uint32_t waited = 0;
    while (waited < timeout_ms) {
        // Check if completed - EITHER status changed OR actual_bytes matches expected
        if (xfer->status != 0 || xfer->actual_num_bytes >= expected_bytes) {
            LOG_I("Transfer done: status=%d, actual=%d", xfer->status, xfer->actual_num_bytes);
            return true;
        }
        vTaskDelay(pdMS_TO_TICKS(1));
        waited++;
    }
    LOG_W("Timeout: status=%d, actual=%d", xfer->status, xfer->actual_num_bytes);
    return false;
}

static bool read_device_id(void) {
    LOG_I("Reading Device ID...");
 
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(64, 0, &xfer) != ESP_OK) return false;
 
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0x81;
    xfer->callback = dummy_cb;  // Required
    xfer->timeout_ms = 3000;
    xfer->num_bytes = 64;
 
    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }
 
    // Poll for completion
    bool done = wait_for_transfer(xfer, 3000, 16);  // Expect at least 16 bytes
 
    bool success = false;
    if (done && xfer->actual_num_bytes >= 16) {
        char ascii_buf[33] = {0};
        for (int i = 0; i < 16; i++) sprintf(&ascii_buf[i*2], "%02x", xfer->data_buffer[i]);
        LOG_I("Device ID: %s", ascii_buf);
        success = true;
    }
 
    usb_host_transfer_free(xfer);
    return success || done;  // Accept partial success
}

static bool send_chunk(uint8_t *data, size_t len) {
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(len, 0, &xfer) != ESP_OK) return false;
 
    memcpy(xfer->data_buffer, data, len);
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0x01;
    xfer->callback = dummy_cb;
    xfer->timeout_ms = 5000;
    xfer->num_bytes = len;
 
    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }
 
    bool done = wait_for_transfer(xfer, 5000, len);
    bool success = (done && (xfer->status == USB_TRANSFER_STATUS_COMPLETED || xfer->actual_num_bytes == len));
 
    usb_host_transfer_free(xfer);
    return success;
}

static void delay_2ms(void) {
    for (volatile int i = 0; i < 480000; i++);  // ~2ms @ 240MHz
}

static bool send_payload(uint8_t *payload_buf, uint32_t payload_len) {
    LOG_I("Sending %d bytes...", payload_len);
 
    int chunks = 0;
    for (uint32_t offset = 0; offset < payload_len; offset += SEND_CHUNK_SIZE) {
        if (!send_chunk(&payload_buf[offset], SEND_CHUNK_SIZE)) {
            LOG_E("Failed at chunk %d", chunks);
            break;
        }
        chunks++;
        if (chunks % 50 == 0) LOG_I("Sent %d chunks", chunks);
        delay_2ms();
    }
 
    LOG_I("Sent %d chunks", chunks);
 
    // Low buffer fix
    if ((chunks % 2) != 1) {
        uint8_t zero[SEND_CHUNK_SIZE] = {0};
        send_chunk(zero, SEND_CHUNK_SIZE);
    }
 
    return chunks > 0;
}

static void smash_stack(void) {
    LOG_I("Smashing stack...");
 
    size_t total_size = 8 + 0x7000;
    uint8_t *buffer = (uint8_t*)heap_caps_aligned_alloc(64, total_size, MALLOC_CAP_DMA);
    if (!buffer) return;
 
    buffer[0] = 0x82; buffer[1] = 0x00; buffer[2] = 0x00; buffer[3] = 0x00;
    buffer[4] = 0x00; buffer[5] = 0x00; buffer[6] = 0x00; buffer[7] = 0x70;
    memset(buffer + 8, 0, 0x7000);
 
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(total_size, 0, &xfer) != ESP_OK) {
        heap_caps_free(buffer);
        return;
    }
 
    memcpy(xfer->data_buffer, buffer, total_size);
    heap_caps_free(buffer);
 
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0;
    xfer->callback = dummy_cb;
    xfer->timeout_ms = 1000;
    xfer->num_bytes = 0x7008;  // Use 0x7008 (total size) which worked before!
 
    if (usb_host_transfer_submit_control(client_hdl, xfer) != ESP_OK) {
        LOG_E("Submit failed");
        usb_host_transfer_free(xfer);
        return;
    }
 
    wait_for_transfer(xfer, 1000, 0);  // Wait but expect 0 bytes back (crash)
    LOG_I("Smash result: status=%d (error/timeout expected)", xfer->status);
    usb_host_transfer_free(xfer);
}

static void inject_payload(void) {
    LOG_I("=== INJECTION START ===");
 
    if (usb_host_interface_claim(client_hdl, dev_hdl, 0, 0) != ESP_OK) {
        LOG_E("Claim failed");
        return;
    }
    LOG_I("Interface claimed");
    vTaskDelay(pdMS_TO_TICKS(100));
 
    read_device_id();  // Optional
 
    uint8_t *payload_buf = (uint8_t*)heap_caps_aligned_alloc(64, MAX_LENGTH, MALLOC_CAP_DMA);
    if (!payload_buf) return;
 
    memset(payload_buf, 0, MAX_LENGTH);
    *(uint32_t*)payload_buf = MAX_LENGTH;
    uint32_t idx = 0x2a8;
 
    uint32_t *spray = (uint32_t*)&payload_buf[idx];
    for (uint32_t addr = RCM_PAYLOAD_ADDR; addr < INTERMEZZO_LOCATION; addr += 4) {
        *spray++ = INTERMEZZO_LOCATION;
        idx += 4;
    }
 
    memcpy(&payload_buf[idx], intermezzo_bin, sizeof(intermezzo_bin));
    idx += sizeof(intermezzo_bin);
    idx = (PAYLOAD_LOAD_BLOCK - RCM_PAYLOAD_ADDR) + 0x2a8;
 
    FILE *f = fopen("/data/payload.bin", "rb");
    if (f) {
        fseek(f, 0, SEEK_END);
        long fsize = ftell(f);
        fseek(f, 0, SEEK_SET);
        fread(&payload_buf[idx], 1, fsize, f);
        fclose(f);
        idx += fsize;
    }
 
    LOG_I("Payload: %d bytes", idx);
    send_payload(payload_buf, idx);
    heap_caps_free(payload_buf);
 
    smash_stack();
 
    LOG_I("=== DONE ===");
    usb_host_interface_release(client_hdl, dev_hdl, 0);
}

static void usb_host_task(void *arg) {
    while (1) usb_host_lib_handle_events(portMAX_DELAY, NULL);
}

static void injection_task(void *arg) {
    usb_host_client_config_t cfg = {
        .max_num_event_msg = 5,
        .async = {.client_event_callback = usb_event_cb, .callback_arg = NULL}
    };
    esp_err_t err = usb_host_client_register(&cfg, &client_hdl);
    if (err != ESP_OK) {
        LOG_E("USB client registration failed: %d", err);
        vTaskDelete(NULL);
        return;
    }
    LOG_I("USB Client registered, waiting for events...");
 
    while (1) {
        usb_host_client_handle_events(client_hdl, portMAX_DELAY);
        if (device_connected && !injection_done) {
            injection_done = true;
            inject_payload();
            while (device_connected) vTaskDelay(pdMS_TO_TICKS(100));
            injection_done = false;
        }
    }
}

void setup() {
    // If you need to use different pins (e.g., GPIO 44/143) //lolin s3 pro
    //Serial1.begin(115200, SERIAL_8N1, 11, 12); // RX=11, TX=12
    //Serial1.println("Debug output");

    Serial.begin(115200);
    delay(100); // Allow serial connection to establish
 
    Serial.println();
    Serial.println("================================");
    Serial.println("RCM Injector v5.0 - ARDUINO BUILD");
    Serial.printf("CPU: %d MHz\n", ESP.getCpuFreqMHz());
    Serial.println("================================");
 
    LOG_I("Initializing internal storage...");
    init_internal_storage();
 
    FILE *t = fopen("/data/payload.bin", "r");
    if (t) {
        fclose(t);
        LOG_I("payload.bin found in /data");
    } else {
        LOG_W("payload.bin not found in /data");
    }
 
    LOG_I("Installing USB Host...");
    usb_host_config_t host_config = {
        .skip_phy_setup = false,
        .intr_flags = ESP_INTR_FLAG_LEVEL1
    };
    esp_err_t err = usb_host_install(&host_config);
    if (err != ESP_OK) {
        LOG_E("USB Host install failed: %d", err);
        return;
    }
    LOG_I("USB Host installed successfully");
 
    LOG_I("Creating FreeRTOS tasks...");
    xTaskCreatePinnedToCore(usb_host_task, "usb_host", 4096, NULL, 20, NULL, 0);
    xTaskCreatePinnedToCore(injection_task, "inject", 8192, NULL, 19, NULL, 0);
 
    LOG_I("Setup complete. Waiting for Nintendo Switch in RCM mode...");
}

void loop() {
    // Main code runs in FreeRTOS tasks, keep watchdog happy
    vTaskDelay(pdMS_TO_TICKS(1000));
}

ESP32-S3 should have a fat partiton (FFAT), and the main payload in the root of that partiton called payload.bin. That's enough for basic testing of the injection code.
 
Last edited by AmeliaFox,
  • Like
Reactions: VishSunny
I tried it and it worked right away. Awesome! I’ve been looking for something like this for a while, and it’s pure coincidence that I stumbled across your solution today.

At first I had a bit of trouble creating the partition correctly and uploading the payload to the ESP32, but after that everything worked as expected. Right now, though, I still have to reset the device after each run for it to work again. Thanks a lot for your work and for sharing!
 
  • Like
Reactions: AmeliaFox
I tried it and it worked right away. Awesome! I’ve been looking for something like this for a while, and it’s pure coincidence that I stumbled across your solution today.

At first I had a bit of trouble creating the partition correctly and uploading the payload to the ESP32, but after that everything worked as expected. Right now, though, I still have to reset the device after each run for it to work again. Thanks a lot for your work and for sharing!
You're welcome, glad you liked it, That code I posted is just for testing injection and if someone wanted to make their own version with more features but was struggling on the injection part. The final code will have lots more "stuff"in it, I know it worked because I tested it out before posting. What chip did you test it on - ESP32S2 or ESP32S3 ?

If you want to reset the ESP32 after sending the payload, so it's ready to send again, you can just add this line after the the payload has been sent:

Code:
ESP.restart();  //software reset.

That should do what you want, but I will be putting the deepsleep code into my project so the esp32-X uses minimal power once the payload has been injected.

This is the partitions.csv I am using on 16MB flash version of ESP32-S3
Code:
# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xe000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x160000,
app1,     app,  ota_1,   0x170000,0x160000,
ffat,     data, fat,     0x2D0000,0xD20000,
coredump, data, coredump,0xFF0000,0x10000,
Post automatically merged:

That's the same footprint as the RP2040-Tiny commonly used for Picofly, only 0.35mm thicker. Might be a tight fit under the shield. WiFi might not work from inside the shield, though.
I was going to put it on top of the Shield, it should fit "thickness wise" because he Adafruit Trinket M0 has a PCB thickness of approximately 2.75 mm without the USB (3.5mm with it) - Size: 27mm x 15.3mm x 2.75mm

ESP32-S3 Mini Development Board is only 2.45mm thick with components, so it should be slim enought to sit on top of the shield and then not interfere with wifi, I was more concerned about the 18.00mm width and 23.5mm length, I think it should fit looking at but I didn't test this, but looking at pictures of the heat shield there's a depressed channel beside the fan where it should fit without needing to cut the heat heat shield, what do you think?
 
Last edited by AmeliaFox,
  • Like
Reactions: Nephiel
What chip did you test it on - ESP32S2 or ESP32S3 ?

I am using an ESP32-S3 N16R8 (16MB flash).



If you want to reset the ESP32 after sending the payload, so it's ready to send again, you can just add this line after the the payload has been sent:

Code:
ESP.restart();  //software reset.

Thanks, this does the trick. I tried to debug why disconnect detection doesn’t work but couldn’t find the reason. When unplugging the device I see E (...) USBH: Dev 1 EP 0 Error in the serial monitor, but usb_event_cb is not called at all. My USB stack/library might be outdated or the client event loop might not be running as expected. Since ESP.restart() works reliably for me, I’m fine with this workaround.


This is the partitions.csv I am using on 16MB flash version of ESP32-S3

I am currently using this partitions.csv, but that shouldn't make any difference (just gives the app more space):

Code:
# Name,   Type, SubType, Offset,   Size,     Flags
nvs,      data, nvs,     0x9000,   0x5000,
otadata,  data, ota,     0xE000,   0x2000,
app0,     app,  ota_0,   0x10000,  0x330000,
app1,     app,  ota_1,   0x340000, 0x330000,
ffat,     data, fat,     0x670000, 0x980000,
coredump, data, coredump,0xFF0000, 0x10000,

I created a folder fat_data in parallel to my .ino file and put the payload.bin inside.

I had to download the fatfs tools from here: https://github.com/espressif/esp-idf/tree/v5.5.2/components/fatfs

Then created the ffat image from the fat_data folder:
Code:
python3 wl_fatfsgen.py --output_file ffat.img --partition_size 0x980000 --sector_size 4096 --long_name_support fat_data

And uploaded the image to the ESP32:
Code:
python3 -m esptool --chip esp32s3 --port COM8 --baud 921600 write-flash 0x670000 ffat.img
 
  • Like
Reactions: AmeliaFox
If you PM me I can give you a very advanced version for testing that has many more features and probably the most advanced payload injector made yet.

WIFI/AP/Station mode.
Allows your esp32 to connect to you home network, create your own access point or do both at the same time

Custom FTP Server
Custom ftp server that can read/write to fat partition or micro sd card if enabled from the config page.

RGB LED support

Button Support;
Long Press - remove config (if you need to reset to default settings)
Double Press - turn off led
Short press - turn on led

Micro SD card support
Controlled by CS pin, so can use a micro sd card instead of using flash memory - could probably be wired to the switch micro sd controller so you could manage files via wireless instead of USB. Tested with inbuilt micro sd on Lolin s3 pro.

Deep Sleep support
Can shut down the chip after x minutes from booting, or after payload injection or both.

File Manager
Lets you manage all contents on your device such as uploading/downloading/creating folders/deleting all files/delete single files/format fat partition.

File Editor
Uses built in PSRAM to store files in PSRAM memory and lets you edit your files straight from the esp32

Payload Manager
Lets you upload/download set default payload and will put them in the correct folder.

Configuration Manager
Lets you change various settings such as WIFI connection, Assign GPIO pins etc....

Information Page
Show information about the esp32, like free flash memoy, build date.....etc.

Firmware Flasher
Allows you to update firmware from a local file, or local/online web server.

Tar installer
Allows you to install a tar file from a local file or local/online web server

This is non public for now, but if you want to test on your chip let me know, then you won't need to mess about trying to upload payloads by using esptool.
 
Last edited by AmeliaFox,
Great

Sorry if I didn't understand correctly but it works like RCM LOADER ?
Yes, it's an RCM LOADER for V1 switch. It's for using an ESP32-S3 chip instead of a samd21e based chip. There's huge benefits using ESP32-S3 chip over the samd21e.

Dual core, faster, wifi, Bluetooth LE, more storage space for payloads, less wiring required if installing as a mod chip, micro sd card support. The code posted above is basic code just for sending a payload. This is not the final code that will be posted. The final code is now pretty much done is just needing testing for bugs then it will be released. It has a huge amount of features and makes old RCM dongle code look like it was made in the caveman days.
 
  • Like
Reactions: petspeed
@Bratwoscht

Here's the firmware I've been working on, for your chip enable PSRAM (octal) and use this for your partitons.csv

Code:
# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xe000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x160000,
app1,     app,  ota_1,   0x170000,0x160000,
ffat,     data, fat,     0x2D0000,0xD20000,
coredump, data, coredump,0xFF0000,0x10000,

This firmware is very advanced now and tested on the following:
ESP32-S3 16MB Flash with 8MB PSRAM.

If should work on any ESP32-S2 or S3 as long as it has PSRAM, but S3 is better because of dual cores and more memory.

If you want you can use your esp32-s3 as and RCM Loader, or if installing as a modchip you can set the GPIO for the RCM strap from the config page in the web interace menu, set this to GPIO 21 - if wiring to rcm make sure to use a 10k resistor from the GPIO pin you set to the rcm pin on switch. When you first start on your device you will see a network called ESP32 showing on your WIFI, connect to that and then enter 192.168.0.1 into your browser, go to file manager and format your device, then you can upload payloads from the index page. Make sure to set a default payload from that page. For your RGB led I think the GPIO pin you need to set is 48. The button is mapped to pin 0, long press this to remove the config if you lock yourself out of you chip by accident.

You bootloader should have been flashed with these settings:
Settings: ESP32S3 Dev Module
USB CDC on boot - disabled
CPU frequency 240mhz
core debug level - none
USB DFU on boot - disabled
Events run on core 1
Flash mode QIO 80mhz
Flash Size 16MB
JTAG Adapter - disabled
Arduino runs on core 1
USB Firmware MSC on boot - disabled
Partiton scheme - custom
PSRAM - OPI PSRAM
Upload mode - UART0/Hardware CDC
Upload speed 921600
USB Mode - Hardware CDC and JTAG
Zigbee mode - disabled

Anyway, have fun.

FYI, If you have a PS4 that can use the Lapse Exploit, you can also use this as a server to hack your PS4, so two for one - haha. I might even add PS4 exfathax code to this as well then have a universal dongle. (Edit: Done, I now have this as a multi dongle for PS4 and Nintendo switch :-), I'll post the source code soon, once I finish cleaning up some stuff).
 

Attachments

Last edited by AmeliaFox,
I just completed this software now, tested on PS4 for ExatHax/Lapse and RCM Loader for the switch. I thought I'd treat myself to a nice RCM Dongle since the sofware is working good so I bought an Amoled Touch screen dongle, PS4/Switch mode is toggled via the web interface. Without using a browser I wanted a built in touch screen to toggle modes and show the internet IP addresss and also to switch default payloads.

https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-1.8

https://thepihut.com/products/esp32...ouch-display-368-x-448?variant=53995206738305

https://www.waveshare.com/esp32-s3-...9dwp-_x0GR55jXJ9Bl8bfskn6DJqIE7pjbNnEUojPYvmW

It's got a Built in Battery, Mico SD card, Speaker, Touch Screen, On/Off button 16mb flash and 8mb PSRAM and a few other features. So a nice dongle with no soldering or any DIY tinkering, just flash it once, then it's plug and play. It should be here during the week, then I can do the graphics and touch screen code.
 
Last edited by AmeliaFox,
  • Like
Reactions: Nephiel
Update, ESP32-S3-Touch-AMOLED-1.8 has now arrived, the screen is amazing. It's working great as a payload injector. I've installed Square Line Studio for making the graphics for the touch screen stuff for easy payload selection, on/off etc.

I also got the esp32-s3 board for internal modchip, I'm waiting for a breakout board to arrive for easy usb solder points.
s-l1600.png
 
Updated injection code below, uses 98% less memory and payload size is not limited by free memory. Instead of loading the full payload into memory first and then sending, we stream it in 4k chunks instead so we can run more stuff on the esp32-Sx in the background.

Code:
/*
 * RCM Injector for ESP32-S3 - Streaming Version with Auto-Reset
 * 
 * Automatically resets after injection to allow multiple consecutive injections.
 */

#include <Arduino.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "usb/usb_host.h"
#include "esp_vfs_fat.h"
#include "wear_levelling.h"

// USB VID/PID for Nintendo Switch in RCM mode
#define APX_VID 0x0955
#define APX_PID 0x7321

// RCM payload layout constants
#define MAX_LENGTH 0x30298
#define RCM_PAYLOAD_ADDR 0x40010000
#define INTERMEZZO_LOCATION 0x4001F000
#define PAYLOAD_LOAD_BLOCK 0x40020000
#define SEND_CHUNK_SIZE 0x1000

// Component sizes
#define HEADER_SIZE 0x2a8
#define INTERMEZZO_SIZE 68
#define SPRAY_TOTAL_BYTES 0xF000

// Logging macros
#define LOG_I(fmt, ...) Serial.printf("[INFO] " fmt "\n", ##__VA_ARGS__)
#define LOG_E(fmt, ...) Serial.printf("[ERROR] " fmt "\n", ##__VA_ARGS__)
#define LOG_W(fmt, ...) Serial.printf("[WARN] " fmt "\n", ##__VA_ARGS__)

static const uint8_t intermezzo_bin[] = {
    0x44, 0x00, 0x9F, 0xE5, 0x01, 0x11, 0xA0, 0xE3, 0x40, 0x20, 0x9F, 0xE5, 0x00, 0x20, 0x42, 0xE0, 
    0x08, 0x00, 0x00, 0xEB, 0x01, 0x01, 0xA0, 0xE3, 0x10, 0xFF, 0x2F, 0xE1, 0x00, 0x00, 0xA0, 0xE1, 
    0x2C, 0x00, 0x9F, 0xE5, 0x2C, 0x10, 0x9F, 0xE5, 0x02, 0x28, 0xA0, 0xE3, 0x01, 0x00, 0x00, 0xEB, 
    0x20, 0x00, 0x9F, 0xE5, 0x10, 0xFF, 0x2F, 0xE1, 0x04, 0x30, 0x90, 0xE4, 0x04, 0x30, 0x81, 0xE4, 
    0x04, 0x20, 0x52, 0xE2, 0xFB, 0xFF, 0xFF, 0x1A, 0x1E, 0xFF, 0x2F, 0xE1, 0x20, 0xF0, 0x01, 0x40, 
    0x5C, 0xF0, 0x01, 0x40, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x01, 0x40
};

static usb_host_client_handle_t client_hdl = NULL;
static usb_device_handle_t dev_hdl = NULL;
static volatile bool device_connected = false;
static volatile bool injection_done = false;
static volatile bool injection_success = false;

static uint8_t chunk_buffer[SEND_CHUNK_SIZE] __attribute__((aligned(64)));

static void dummy_cb(usb_transfer_t *transfer) { }

static void reset_injection_state(void) {
    injection_done = false;
    injection_success = false;
    LOG_I("Ready for next injection");
}

static void usb_event_cb(const usb_host_client_event_msg_t *event, void *arg) {
    if (event->event == USB_HOST_CLIENT_EVENT_NEW_DEV) {
        usb_device_handle_t test_hdl;
        esp_err_t err = usb_host_device_open(client_hdl, event->new_dev.address, &test_hdl);
        if (err == ESP_OK) {
            const usb_device_desc_t *dev_desc;
            err = usb_host_get_device_descriptor(test_hdl, &dev_desc);
            if (err == ESP_OK && dev_desc->idVendor == APX_VID && dev_desc->idProduct == APX_PID) {
                LOG_I("*** SWITCH RCM DETECTED ***");
                dev_hdl = test_hdl;
                device_connected = true;
                return;
            }
            usb_host_device_close(client_hdl, test_hdl);
        }
    } else if (event->event == USB_HOST_CLIENT_EVENT_DEV_GONE) {
        LOG_I("*** SWITCH DISCONNECTED ***");
        device_connected = false;
        dev_hdl = NULL;
        
        // Reset state when Switch disconnects (after injection or manual disconnect)
        if (injection_done) {
            reset_injection_state();
        }
    }
}

static esp_err_t init_internal_storage(void) {
    esp_vfs_fat_mount_config_t mount_config = {};
    mount_config.format_if_mount_failed = true;
    mount_config.max_files = 2;
    mount_config.allocation_unit_size = 4096;
    
    wl_handle_t wl_handle;
    esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl("/data", "ffat", &mount_config, &wl_handle);
    if (err != ESP_OK) {
        LOG_E("FATFS mount failed: %s", esp_err_to_name(err));
        return err;
    }
    LOG_I("FATFS mounted at /data");
    return ESP_OK;
}

static bool wait_for_transfer(usb_transfer_t *xfer, uint32_t timeout_ms, size_t expected_bytes) {
    uint32_t waited = 0;
    while (waited < timeout_ms) {
        if (xfer->status != 0 || xfer->actual_num_bytes >= expected_bytes) {
            return true;
        }
        vTaskDelay(pdMS_TO_TICKS(1));
        waited++;
    }
    return false;
}

static bool read_device_id(void) {
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(64, 0, &xfer) != ESP_OK) return false;
    
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0x81;
    xfer->callback = dummy_cb;
    xfer->timeout_ms = 3000;
    xfer->num_bytes = 64;
    
    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }
    
    bool done = wait_for_transfer(xfer, 3000, 16);
    
    if (done && xfer->actual_num_bytes >= 16) {
        char ascii_buf[33] = {0};
        for (int i = 0; i < 16; i++) sprintf(&ascii_buf[i*2], "%02x", xfer->data_buffer[i]);
        LOG_I("Device ID: %s", ascii_buf);
    }
    
    usb_host_transfer_free(xfer);
    return done;
}

static bool send_chunk(uint8_t *data) {
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(SEND_CHUNK_SIZE, 0, &xfer) != ESP_OK) return false;
    
    memcpy(xfer->data_buffer, data, SEND_CHUNK_SIZE);
    
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0x01;
    xfer->callback = dummy_cb;
    xfer->timeout_ms = 5000;
    xfer->num_bytes = SEND_CHUNK_SIZE;
    
    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }
    
    bool done = wait_for_transfer(xfer, 5000, SEND_CHUNK_SIZE);
    bool success = (done && (xfer->status == USB_TRANSFER_STATUS_COMPLETED || 
                             xfer->actual_num_bytes == SEND_CHUNK_SIZE));
    
    usb_host_transfer_free(xfer);
    return success;
}

static void delay_2ms(void) {
    for (int i = 0; i < 480000; ++i) {}
}

static bool stream_payload(void) {
    int chunks = 0;
    
    // Header
    memset(chunk_buffer, 0, SEND_CHUNK_SIZE);
    *(uint32_t*)chunk_buffer = MAX_LENGTH;
    if (!send_chunk(chunk_buffer)) return false;
    chunks++;
    delay_2ms();
    
    // Spray
    uint32_t spray_sent = 0;
    while (spray_sent < SPRAY_TOTAL_BYTES) {
        memset(chunk_buffer, 0, SEND_CHUNK_SIZE);
        uint32_t batch = (SPRAY_TOTAL_BYTES - spray_sent >= SEND_CHUNK_SIZE) ? 
                          SEND_CHUNK_SIZE : (SPRAY_TOTAL_BYTES - spray_sent);
        uint32_t num_addrs = batch / 4;
        uint32_t *ptr = (uint32_t*)chunk_buffer;
        for (uint32_t i = 0; i < num_addrs; i++) {
            ptr[i] = INTERMEZZO_LOCATION;
        }
        if (!send_chunk(chunk_buffer)) return false;
        spray_sent += batch;
        chunks++;
        delay_2ms();
    }
    
    // Intermezzo
    memset(chunk_buffer, 0, SEND_CHUNK_SIZE);
    memcpy(chunk_buffer, intermezzo_bin, INTERMEZZO_SIZE);
    if (!send_chunk(chunk_buffer)) return false;
    chunks++;
    delay_2ms();
    
    // Gap
    memset(chunk_buffer, 0, SEND_CHUNK_SIZE);
    if (!send_chunk(chunk_buffer)) return false;
    chunks++;
    delay_2ms();
    
    // Payload file
    FILE *f = fopen("/data/payload.bin", "rb");
    if (!f) {
        LOG_E("Failed to open /data/payload.bin");
        return false;
    }
    
    size_t read;
    while ((read = fread(chunk_buffer, 1, SEND_CHUNK_SIZE, f)) > 0) {
        if (read < SEND_CHUNK_SIZE) {
            memset(chunk_buffer + read, 0, SEND_CHUNK_SIZE - read);
        }
        if (!send_chunk(chunk_buffer)) {
            fclose(f);
            return false;
        }
        chunks++;
        delay_2ms();
    }
    fclose(f);
    
    // Low buffer fix
    if ((chunks % 2) != 1) {
        memset(chunk_buffer, 0, SEND_CHUNK_SIZE);
        send_chunk(chunk_buffer);
    }
    
    return true;
}

static void smash_stack(void) {
    size_t total_size = 8 + 0x7000;
    uint8_t *buffer = (uint8_t*)heap_caps_aligned_alloc(64, total_size, MALLOC_CAP_DMA);
    if (!buffer) return;
    
    buffer[0] = 0x82; buffer[1] = 0x00; buffer[2] = 0x00; buffer[3] = 0x00;
    buffer[4] = 0x00; buffer[5] = 0x00; buffer[6] = 0x00; buffer[7] = 0x70;
    memset(buffer + 8, 0, 0x7000);
    
    usb_transfer_t *xfer = NULL;
    if (usb_host_transfer_alloc(total_size, 0, &xfer) != ESP_OK) {
        heap_caps_free(buffer);
        return;
    }
    
    memcpy(xfer->data_buffer, buffer, total_size);
    heap_caps_free(buffer);
    
    xfer->device_handle = dev_hdl;
    xfer->bEndpointAddress = 0;
    xfer->callback = dummy_cb;
    xfer->timeout_ms = 1000;
    xfer->num_bytes = 0x7008;
    
    if (usb_host_transfer_submit_control(client_hdl, xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return;
    }
    
    wait_for_transfer(xfer, 1000, 0);
    usb_host_transfer_free(xfer);
}

static void inject_payload(void) {
    LOG_I("=== INJECTION START ===");
    injection_success = false;
    
    if (usb_host_interface_claim(client_hdl, dev_hdl, 0, 0) != ESP_OK) {
        LOG_E("Interface claim failed");
        return;
    }
    
    vTaskDelay(pdMS_TO_TICKS(100));
    read_device_id();
    
    if (!stream_payload()) {
        LOG_E("Payload streaming failed");
        usb_host_interface_release(client_hdl, dev_hdl, 0);
        return;
    }
    
    smash_stack();
    injection_success = true;
    
    LOG_I("=== INJECTION COMPLETE ===");
    LOG_I("Waiting for disconnect to reset...");
    usb_host_interface_release(client_hdl, dev_hdl, 0);
}

static void usb_host_task(void *arg) {
    while (1) usb_host_lib_handle_events(portMAX_DELAY, NULL);
}

static void injection_task(void *arg) {
    usb_host_client_config_t cfg = {};
    cfg.max_num_event_msg = 5;
    cfg.async.client_event_callback = usb_event_cb;
    cfg.async.callback_arg = NULL;
    
    esp_err_t err = usb_host_client_register(&cfg, &client_hdl);
    if (err != ESP_OK) {
        LOG_E("USB client registration failed: %d", err);
        vTaskDelete(NULL);
        return;
    }
    
    LOG_I("Ready - waiting for Nintendo Switch in RCM mode...");
    
    while (1) {
        usb_host_client_handle_events(client_hdl, portMAX_DELAY);
        
        if (device_connected && !injection_done) {
            injection_done = true;
            inject_payload();
        }
        
        // Auto-reset: if injection succeeded but device disconnected, reset state
        if (injection_success && !device_connected && injection_done) {
            reset_injection_state();
        }
    }
}

void setup() {
    Serial.begin(115200);
    delay(100);
    
    Serial.println();
    Serial.println("================================");
    Serial.println("RCM Injector - Auto-Reset Version");
    Serial.printf("CPU: %" PRIu32 " MHz\n", ESP.getCpuFreqMHz());
    Serial.println("================================");
    
    init_internal_storage();
    
    FILE *t = fopen("/data/payload.bin", "r");
    if (t) { 
        fseek(t, 0, SEEK_END);
        LOG_I("payload.bin ready: %ld bytes", ftell(t));
        fclose(t); 
    } else {
        LOG_W("payload.bin not found - upload required");
    }
    
    usb_host_config_t host_config = {};
    host_config.skip_phy_setup = false;
    host_config.intr_flags = ESP_INTR_FLAG_LEVEL1;
    
    esp_err_t err = usb_host_install(&host_config);
    if (err != ESP_OK) {
        LOG_E("USB Host install failed: %d", err);
        return;
    }
    
    xTaskCreatePinnedToCore(usb_host_task, "usb_host", 4096, NULL, 20, NULL, 0);
    xTaskCreatePinnedToCore(injection_task, "inject", 8192, NULL, 19, NULL, 0);
}

void loop() {
    vTaskDelay(pdMS_TO_TICKS(1000));
}

Have fun.
 
  • Love
Reactions: Jayro
Updated injection code below, uses 98% less memory and payload size is not limited by free memory. Instead of loading the full payload into memory first and then sending, we stream it in 4k chunks instead so we can run more stuff on the esp32-Sx in the background.

Have fun.
Truly incredible work you've done here!
 
  • Like
Reactions: AmeliaFox
Truly incredible work you've done here!
Attached is a version that loads the payload from an embedded array instead of needing to use a ffat/spiffs partiton. There's also a tool included to convert payloads into an array header file so it should be easy for those that want to do it this way. It's been tested on esp32-s3, it should also work with esp32-s2 (but I didn't test on that yet as I don't have time).
 

Attachments

I'd just like to say you've done some impeccable work. I went with a seed studio esp32-s3 ( https://a.co/d/0bCl9ND7) since it's itty bitty and can fit in my switch case and have it all set up and working well. (Did need to tweak some memory allocation stuff to make the firmware fit). Now I don't have to have a computer around just because my switch died.

Really appreciate what you've done and this as a solution for an esp32 fs is insanely polished compared to my previous attempts. Great job.
 
  • Like
Reactions: AmeliaFox
I'd just like to say you've done some impeccable work. I went with a seed studio esp32-s3 ( https://a.co/d/0bCl9ND7) since it's itty bitty and can fit in my switch case and have it all set up and working well. (Did need to tweak some memory allocation stuff to make the firmware fit). Now I don't have to have a computer around just because my switch died.

Really appreciate what you've done and this as a solution for an esp32 fs is insanely polished compared to my previous attempts. Great job.
Thats's great, I've actually been pretty busy as of late so haven't had a chance yet to carry on with this. Now I have a question to ask since you already installed, for putting your switch into RCM mode are you using the chip to do it or do you have autorcm enabled? I still need to do the rcm code stuff and test it. For RCM, I was thinking to wire like this (I'm not a hardware guy), then set the GPIO to floating once the chip boots, once the payload is sent put the chip into deep sleep and the the GPIO should stay in a floating state until the switch is fully powered off again. (and resets the power rails). Or maybe use mosfets instead for power efficiency?

1.png

mosfet.png




Full lifecycle of the modchip GPIO
🔴 0. Power OFF (console fully off)
Modchip has no power
GPIO pins are:
❌ Not driven
❌ Not high or low
✅ Electrically “disconnected” (Hi-Z by nature of no power)

👉 Important:

The MOSFET gate is held LOW by a pull-down resistor
So:
MOSFET = ON
RCM line = grounded

✔️ This already sets up the failsafe condition

🟡 1. Power button pressed (early power ramp)
Power rails start rising
NVIDIA Tegra X1 begins BootROM very quickly
Modchip MCU is:
still in reset / not executing code yet
GPIO state:
Still Hi-Z (input mode, unconfigured)
Circuit behavior:
Pull-down on MOSFET gate keeps it ON
RCM line = LOW

✔️ This is the critical moment → RCM is detected

🟠 2. BootROM samples RCM pin
Happens very early
Sees:
LOW → enter RCM

👉 At this point, the job is already done

🟢 3. Modchip MCU boots

Now the modchip firmware finally starts running.

Initial GPIO configuration:

Typically one of:

Sets GPIO → HIGH
Or sets GPIO → output + HIGH
Or briefly toggles depending on design
Effect:
Gate of MOSFET driven HIGH
MOSFET = OFF
RCM line released
Then the 10k pull-up pulls line → HIGH

🔵 4. Active phase (payload / logic)
GPIO is actively controlled
Could be:
HIGH (keeping MOSFET off)
Or switched as needed

👉 But importantly:

RCM line is no longer forced LOW

⚫ 5. Deep sleep (very important)

Now the modchip goes into low-power mode.

GPIO behavior depends on firmware + MCU:
✅ Most common:
GPIO set to input (Hi-Z)
Internal pulls:
Disabled or weak
What happens electrically:
External pull-down on MOSFET gate:
Pulls gate LOW again

👉 That means:

MOSFET = ON
RCM line = LOW again

⚠️ Wait — doesn’t that re-trigger RCM?

Good catch — this is where designs differ:

Two possibilities:
✅ Design A (common in simple circuits)
Deep sleep → MOSFET turns ON again
Next boot → always enters RCM

✔️ This is fine if:
You always want payload injection

✅ Design B (more advanced modchips)
MCU ensures:
Gate is held HIGH even in sleep
OR
Uses a latch / different topology

👉 Result:

MOSFET stays OFF
RCM not retriggered unintentionally

🔑 Clean state table
Phase GPIO state MOSFET RCM line
Power OFF Unpowered (Hi-Z) ON LOW
Power ramp Hi-Z ON LOW
BootROM check Hi-Z ON LOW
MCU boots Output HIGH OFF HIGH
Active Driven HIGH OFF HIGH
Deep sleep Hi-Z (typical) ON* LOW*

🧠 Final mental model (this is the “aha”)

👉 GPIO is irrelevant at boot
👉 Hardware guarantees the LOW

Then:

MCU wakes up → overrides hardware
MCU sleeps → hardware may take over again

⚡ One-line summary

The GPIO never “starts as ground” — it starts as inactive, while the circuit around it forces the correct state until firmware takes control.

I'm thinking the mosfet design would be better as it would prevent any issues when the switch wakes up from sleep mode. Where the first design might have some issues with that. What do you think? I'm not a hardware/electonics person so I don't know.
Post automatically merged:

Screenshots of upcoming firmware:
1.png
2.png
3.png
4.png
5.png
6.png
7.png

8.png


I've added bluetooth as well now, so you can find the ip address easily with your modchip is connected to your phone or router, the chip also has it's own server so you can easily manage your payloads. This firmware and code will be released soon(ish).
Post automatically merged:

For those that know how to compile this, here's the full code with all the new advanced features.

First flash, make sure to use a custom partitions.csv file and put it in the same folder as the source code, then in ArduinoIDE make sure to select the following options when compiling;

ESP32S3 Dev Module
Boards manager - at least ESP32 3.37 (expressif)
Use CDC on Boot - disabled
CFU Frequency 240 MHz
Core debug level - none
Use DFU on boot - disabled
Erase all flash before updoad - true
Events run on core 1
Flash Mode QIO 80Mhz
Flash Size - whatever your board is....
Jtag Adapter - disabled
Arduino runs on core 1
Use Firmware MSC on boot - disabled
Partition scheme - custom (make sure at least 0x190000 for app0/app1)
PSRAM - OPSRAM (octal in most cases)
Upload Mode - UART0
Upload Speed - 921600
USB Mode - Hardware CDC and JTAG
Zigbee Mode - disabled.

See readme.txt, also check out RCM GPIO Explained for a wiring diagram and info if you want to use the RCM pin and use that for wiring into your switch, using that diagram is the safest way to prevent any wakeup issues.

RCM GPIO can be set in the web interface, use a GPIO from your board which is safe and is in a floating state when the chip is powered off. By default the first time you boot the chip a web server/access point will start - you can enter this by connection to esp32 ssid on your computer, then go to ip adrress http://192.168.0.1.
From that adress you can upload your payloads to the chip, then go into the configuration page and set up your wifi, onboard led, deepsleep mode etc...
If connection to your home router, you can also enable bluetooth. You will be able to scan for bluetooth connections from your phone and it will show you the modchips IP address, this saves you needing to look at your routers interface.

Anyway, this has now been tested quite a bit, for modders, you can mess about with RCM code, basically the current code sets the assigned GPIO pin high (from floating) when the chip boots. I recommend making a little custom rcm board for using this, if only needs a few parts and is easy to make, this saves on battey power, prevents any rcm issues.


For this with an ESP32-S3 dongle instead of using an installed modchip - no need to mess about with any RCM stuff, just flash and go. Tested on the following: Waveshare ESP32-S3-Touch-AMOLED-1.8 - with built in battery.

ESP32-S3 (various but all need PSRAM for extra features).

If you don't have PSRAM, I recommend just using one of the previous codes blocks from above, the one with payload streaming, then mod it how you like.

Any bugs - nope, none that I know off....but report them if you find any.
Post automatically merged:


EDIT: Source code complete now, will upload in a while once it's fully tested, all bluetooth code is now complete, I just need to make an Android app for selecting payloads on the esp32 and that's this project complete. :-)
 
Last edited by AmeliaFox,

Site & Scene News

Popular threads in this forum