Skip to content
 
 

Repository files navigation

pokeldn: ESP32-powered local wireless toolkit for Pokémon on Nintendo Switch

Discord Documentation Python 3.13

pokeldn

An ESP32 board on USB serial is the radio: pokeldn hosts or joins Nintendo Switch local wireless (LDN) sessions with retail Pokémon games through it, from any computer that runs Python. Nothing is installed on the Switch or Switch 2. Seven games are supported:

FRLG LGPE SwSh BDSP PLA SV PLZA
Trade ✓ ✓ ✓ ✓ ✓ ✓ ✓
Mystery Gift ✓ ∅ ✓ ∅ ∅ ∅ ∅
Link battle ✓ ✗ ✗ ✗ ∅ ✗ ✗
Code on the console, save read and write ✓ ✗ ✗ ✗ ✗ ✗ ✗

✓ works on a retail console · ✗ not done · ∅ the game has no such feature over local wireless FRLG FireRed/LeafGreen · LGPE Let's Go Pikachu/Eevee · SwSh Sword/Shield · BDSP Brilliant Diamond/Shining Pearl · PLA Legends Arceus · SV Scarlet/Violet · PLZA Legends Z-A

Every game trades through the ESP32 board. Protocol documentation: decryptu.github.io/pokeldn.


Why?

Direct local wireless communication with retail Pokémon games, and the protocols documented for work such as an unofficial GTS or online battles. AI tools helped reverse engineer the protocols and write parts of the code.

Desktop app

The pokeldn desktop app offering a shiny Ditto for a FireRed trade

The releases carry a desktop app for macOS (Apple silicon), Windows and Linux. It includes the radio firmware and flashes the board, builds legal Pokemon to offer with PKHeX.Core, and runs every trade and Mystery Gift below with the tested settings. The only file it asks for is prod.keys.

  • macOS: the app is unsigned, so the first launch is blocked. Open it once and close the warning, then System Settings, Privacy & Security, scroll down to Security, Open Anyway next to pokeldn, and confirm with your password. Later launches open normally.
  • Windows: SmartScreen may stop the unsigned app; choose More info, then Run anyway.
  • Linux: it needs GTK 3 and libsecret, present on desktop distributions, and serial access (sudo usermod -aG dialout $USER; the group is uucp on Arch).
  • From source: pip install -r gui/requirements.txt, then python gui/main.py; the Pokemon builder needs dotnet build -c Release services/pkhex (.NET 10 SDK), and file drops need the client python scripts/build_client.py builds (Flutter). python scripts/pack_app.py builds the app for the current OS into dist/, with firmware and that client required. See desktop builds.
  • python -m pokeldn --list lists the shared GUI/CLI presets. For example, python -m pokeldn --radio esp32:auto swsh-host --offer-file offer.pk8. See code organization for the shared modules and legality checks.

Mystery Gift files

The FRLG and Sword/Shield Mystery Gift tools send a preset, a gift built in the app, or a shared .pokegift file; FRLG also accepts .wc3 and Sword/Shield .wc8. FRLG builds Wonder Cards, Wonder News and ARM console code; Sword/Shield builds Pokemon, eggs, items and Battle Points. Save gift file exports the selected gift without a board.

./.venv/bin/python bin/frlg_mg_host.py --gift celebi --export-gift celebi.pokegift
./.venv/bin/python bin/swsh_gift_host.py --species 25 --export-gift pikachu.pokegift
./.venv/bin/python bin/frlg_mg_host.py --buffer-script trainer-id-probe --export-gift probe.pokegift
./.venv/bin/python -m pokeldn.gifts inspect celebi.pokegift

Both launchers accept --gift-file FILE. Native conversion and the file schema are in Mystery Gift files.

Requirements

  • A classic ESP32 board with a USB serial bridge, or an ESP32-S3, ESP32-C3 or ESP32-C6 through native USB Serial/JTAG, flashed with firmware/esp32 for its chip. All use 2.4 GHz. Board requirements and hardware verification are on ESP32 radio.
  • Optional: a 128x64 SSD1306 I2C OLED on the board (classic ESP32: SDA D21, SCL D22, VCC 3V3) shows the radio's traffic, the Pokemon each trade sends and receives, and the Mystery Gift card; idle, it dims after a minute and turns off after ten, and BOOT wakes it (The screen).
  • Python 3.11+ and a venv with requirements.txt installed. No root. The bundled vendor/LDN is installed by it; do not substitute the PyPI ldn package.
  • Source trade tools also need the .NET 10 SDK and dotnet build -c Release services/pkhex. Released desktop apps include the helper.
  • A Switch or Switch 2 with one of the games. FireRed / LeafGreen needs the Direct Corner unlocked (20 to 40 minutes of play) and at least two .pk3 party members.
  • Switch prod.keys (default ~/.switch/prod.keys; --keys PATH elsewhere, absolute under sudo).

Setup

The desktop app flashes the board from its Board page; building the firmware is optional. A copy run from source has no image until its Board page's Download the firmware fetches the firmware images of the latest release and checks them against its SHA256SUMS.

Building it yourself needs ESP-IDF v6.1, which provides idf.py; this repository does not ship it:

git clone -b v6.1 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
~/esp/esp-idf/install.sh esp32,esp32s3,esp32c3,esp32c6
. ~/esp/esp-idf/export.sh   # puts idf.py on PATH, once per shell
cd firmware/esp32
idf.py set-target esp32   # esp32s3, esp32c3 or esp32c6 for those chips
idf.py build
idf.py -p PORT flash
cd ../..
./.venv/bin/python tools/ldn/esp32_first_contact.py --port PORT           # HELLO, counters, networks
export POKELDN_RADIO=esp32:auto

PORT is the board's serial device (/dev/cu.usbserial-* or /dev/cu.usbmodem* on macOS, /dev/ttyUSB* or /dev/ttyACM* on Linux, COM4 on Windows) and follows the USB socket. esp32:auto takes the only USB serial port present; esp32:PORT names one. An S3, C3 or C6 board with two USB sockets needs its native USB socket for radio communication. POKELDN_ESP32_TRACE=FILE records every serial message and the board's counters. The exact IDF version is on ESP32 radio.

A Linux Wi-Fi card (TP-Link Archer T3U, ALFA AWUS036ACHM, Realtek RTL8821CE) still works as root without POKELDN_RADIO, with NetworkManager kept off the LDN interfaces; it is no longer developed. See Adapters.

Layout

bin/ entry points, named for the game: frlg_*, lgpe_*, swsh_*, bdsp_*, pla_*, sv_*, za_* (_host hosts, _join / _connect joins; bin/X --help lists flags)
tools/ldn/ the radio, any target: esp32_first_contact.py, esp32_sniff.py (second board as air sniffer), ldn_scan.py
tools/frlg/, tools/switch/ offline readers: a FireRed console's dumps; a retail Switch title's own code
pokeldn/ the package: ldn/ wireless layer, gba/ GBA link, one package per game, gen8.py and gen9.py shared Pokémon codecs
pokeldn/app/, services/pkhex/, gui/ shared tool runtime; PKHeX service; desktop views
firmware/esp32/, asm/ the radio's firmware; ARM sources for the payloads the console runs
scripts/, config/, vendor/ setup and code generation; host profiles; bundled LDN and the mt7601u driver
docs/, tests/ the protocol findings, with citations; python -m pytest tests/ -q

Run entry points from the repo root with POKELDN_RADIO set, as ./.venv/bin/python -u bin/NAME.py .... Config files and default output paths resolve against the working directory.

Usage

FireRed and LeafGreen

./.venv/bin/python bin/frlg_trade_join.py --live -o output.pk3 PARTY1.pk3 PARTY2.pk3   # join the Switch's trade
./.venv/bin/python bin/frlg_trade_host.py -o output.pk3 PARTY1.pk3 PARTY2.pk3          # host a Direct Corner trade

The host advertises the group and leads the trade. By default it offers PARTY2.pk3 and writes what it receives to output.pk3. Defaults come from config/host.toml, then the ignored config/host.local.toml; flags override both; --print-effective-config shows the result.

  1. Run the host and wait for Hosting Direct Corner.
  2. On the Switch: Direct Corner, Join Group, pick pokeldn's trainer. Wait until the host reports that trade selection is active.
  3. Select the Pokémon to trade away and confirm.
  4. The console returns to the trade menu after each trade. --trades N (1 to 6) offers party slots 0 to N-1, one per trade, on the same link. After the last trade, wait for the host prompt, then CANCEL, YES; the room exit and disconnect follow on their own.
flag purpose
--keys PATH prod.keys location
--slot N zero-based party member offered
--capture FILE JSONL diagnostic capture
--config / --local-config / --no-local-config replace or disable a config layer
--ot NAME, --version firered|leafgreen, --id TID[:SID] per-run trainer overrides (0..65535 each; the LinkPlayer ID is (SID << 16) | TID)
--verbose per-packet output, logged synchronously inside the frame-timed loop; use it with --replay only

DEFAULT_TRAINER in pokeldn/config.py holds the defaults with no flag (gender, language, National Dex). The link protocol is in The link protocol.

Union Room. --union-room advertises on the middle NPC's path; the console shows itself connected after the keepalive wait, about 10 s (The link protocol). A Union Room link carries one trade, then the console returns to the field (union_room.c:1744). --board-type normal registers the offered Pokémon on the trading board, --union-room-chat with --chat-message / --chat-file chats, --union-room-battle --battle-fight battles (the console needs two non-egg Pokémon at level 30 or lower).

./.venv/bin/python bin/frlg_trade_host.py --union-room --union-room-keepalive 120 PARTY1.pk3 PARTY2.pk3

Mystery Gift. bin/frlg_mg_host.py advertises on the Friend path and sends a Wonder Card plus a delivery script. On the Switch: Mystery Gift → Wonder Cards → Friend, then pokeldn's host; the save must have Mystery Gift unlocked. --make-artifact writes a .ram.lst listing of the bytes sent. The catalogue, authoring system and Friend path requirement are in Mystery Gift.

./.venv/bin/python -u bin/frlg_mg_host.py --gift beast-cutscene --flag-id 1005 --capture mystery-gift.jsonl

The beast follows the save's starter (Bulbasaur Suicune, Squirtle Entei, Charmander Raikou). A shared Stamp Rally card is --gift solrock-stamp and --gift lunatone-stamp, in either order.

Wonder News. --news serves the console's second Mystery Gift column (Mystery Gift → Wonder News → Friend): 444 bytes of title and body, rewarding a berry in Cerulean City. A console keeps news only if it differs from what it holds; --news-id N forces a new one.

./.venv/bin/python -u bin/frlg_mg_host.py --news berry --news-id 7

Console save. A Mystery Gift session runs native ARM code on the console. save-dump reads the live save back (secret ID, every party Pokémon's PID, IVs and nature); nothing is written. flash-patch edits one field: it reads the save sector, changes only the named bytes, recomputes the checksum, writes the sector back and bumps a counter so the game loads it. It edits a real save; read A RAM snapshot is not a save first. Payloads: Code on the console.

./.venv/bin/python -u bin/frlg_mg_host.py --buffer-script save-dump --dump-block sav2 --dump-size 64 --dump-file dump.bin
./.venv/bin/python tools/frlg/dump_read.py dump.bin --block sav2
./.venv/bin/python -u bin/frlg_mg_host.py --buffer-script flash-patch --flash-id 0 \
  --flash-patch-offset 0x00 --flash-patch-hex cac9c5bfc6bec8ff --write-unsafe

Let's Go Pikachu and Eevee

The trade screen alternates hosting and scanning, so pokeldn can host or join. Both send a kind-1 identity message: the joiner the one pokeldn.lgpe.reference ships, the host the console's own back (--first echo) or a file. Both offer a 232-byte PB7 (pokeldn.lgpe.pb7; --offer echo returns the console's own). The host's --next-offer and the joiner's repeated --offer queue one record per later trade on the same seat. The joiner's --leave-after S backs out S seconds after it answers the first trade step, the way a player leaves the trade screen; without it the seat stays up for later trades.

./.venv/bin/python bin/lgpe_host.py --seconds 600 --player-name POKELDN \
  --first echo --our-trainer 41234:12345 --offer offer.pb7
./.venv/bin/python bin/lgpe_join.py --connect --connect-seconds 300 \
  --ack-peer-clock --ack-re-announce \
  --our-trainer 41234:12345 --offer offer.pb7

Console: Communiquer, Communication locale, Échange, link code Pikachu ×3, wait on the search screen. See Let's Go.

Sword and Shield

Console: Y-Comm → Link Trade → local communication, A on both messages, wait on the search screen.

# join the console's session and trade: the console's own party snapshot is sent back, rewritten
POKELDN_RADIO=esp32:auto ./.venv/bin/python bin/swsh_connect.py --keys PROD_KEYS \
  --preset trade --offer-slot 1 [--offer-file your.pk8 --offer-file next.pk8 --fresh-pid] \
  --save-offered received.pk8

# or host, and let the console join: its snapshot is taken from this trade
POKELDN_RADIO=esp32:auto ./.venv/bin/python bin/swsh_host.py --keys PROD_KEYS \
  --offer-file your.pk8 --fresh-pid --received received.pk8 --channel 6 --seconds 900

The host builds its own station advertisement and rewrites the joining console's live snapshot. When hosting, the console joins from Y-Comm → Link Trade → trade, after A on both messages that follow; --received FILE saves what it sends, --code 12345678 hosts for a Link Code search. --advert and --snapshot still accept saved records for comparison. The host and the joiner take a repeated --offer-file, one per trade on the session, as the player picks again from the box; trade N writes what it received with -N. Details: Trading.

Mystery Gift needs no session; the gift screen scans and a distributor advertises the card. Console: Mystery Gift → receive a gift → via local wireless.

./.venv/bin/python bin/swsh_gift_host.py --species 25 --level 25 \
  --move1 84 --move2 45 --move3 86 --move4 98 --nickname POKELDN --ot POKELDN --seconds 300
./.venv/bin/python bin/swsh_gift_host.py --record card.wc8 --seconds 300

--set FIELD=VALUE sets any record field (shiny_type=3, ball=1, held_item=236, iv_hp=31, gigantamax=1), --ribbon N adds a ribbon, --dump FILE writes the record without a radio. Item ids are checked against the item table; an id with no row is accepted and then aborts the game when its bag row is shown. See Mystery Gift.

Brilliant Diamond and Shining Pearl

pokeldn joins the console's Union Room as a character. Console: any Pokémon Center → 2F → left attendant → plain "yes" (not the password or group option), then wait in the Union Room clear of the walls.

./.venv/bin/python tools/ldn/ldn_scan.py --channels 1,6,11 --dwell 0.8      # see the session
./.venv/bin/python bin/bdsp_connect.py --channels 1,6,11 --count 9 --connect 5 --join 6 \
  --hold 420 --reliable-ack --reliable-sweep 3 --room-walk 15 --room-pattern fixed \
  --room-walk-steps 0 --join-avatar 0 --answer-requests --state 0 --recruiting 0 \
  --answer-talk --can-talk 0 --initiate-talk --initiate-delay 3 \
  --after-approach 0x06:0001000000 --trade-reply --complete-trade \
  --trade-template offer.pb8 --trade-nickname POKELDN --src-var 0x2B7F4C12

Association can fail (Connect failed with status code 1); retry the run before diagnosing (Session). --room-pattern fixed sends joins until the console asks for the character's state, at most --room-walk (15 above). A repeated --trade-template queues one Pokémon per trade in the session; the last is offered again. Use a fresh --src-var every run (the console keeps ids it has seen), and after a hand-stopped run the player leaves and re-enters the room. --complete-trade lets the console write its save; without it the trade stops at the last confirmation. Once the character has appeared and finished walking: Y → communication menu → trade Pokémon.

To host instead, start the host first, then the player enters the room the same way and raises the trade emote (Y → communication menu → trade Pokémon):

./.venv/bin/python bin/bdsp_host.py --offer offer.pb8 --complete-trade --capture bh01.jsonl

--offer must be a legal PB8 whose PID the save does not hold; repeated, it queues one per trade, the last offered again. --password 00000000 hosts a room entered with that password. See Brilliant Diamond and Shining Pearl.

Legends Arceus

A console hosting a trade hands the host role to the station that joins (Legends Arceus). bin/pla_host.py hosts and the console joins by link code; bin/pla_join.py joins the console's search and takes the host role it is handed.

./.venv/bin/python bin/pla_host.py --code 00000000 --channel 6 --seconds 1800 \
  --session-update --sustain --clock --data-exchange --data-exchange-name POKELDN \
  --data-exchange-id 11223344 --game-channel --trade-box --trade-box-record offer.pa8 \
  --offer-out received.pa8
./.venv/bin/python bin/pla_host.py --ip-host --our-ip 172.16.86.128 --code 00000000 ...   # emulated console, no radio

Console: Simona at Jubilife Village → trade → someone nearby → the same eight-digit code, offer a Pokémon and confirm. The host re-reads its record file between offers and writes the record the console traded to --offer-out (-2 and on for later trades); --trade-box-collect DIR keeps every record the console shows or offers, its cursor included. A repeated --trade-box-record queues one record per trade in the session, the last offered again. pokeldn.pla.pokemon reads, writes and builds a record from 376 zero bytes; pokeldn.pla.stats computes stats and size. See Legends Arceus.

Scarlet and Violet

The offline Link Trade search alternates scanning and hosting, so pokeldn hosts (bin/sv_host.py) or joins (bin/sv_join.py).

./.venv/bin/python bin/sv_host.py --seconds 240 --player-name POKELDN \
  --rtt-probe --net-property --clock --net-stations 4 --scarlet-response \
  --record-delay 0.17 --announce --announce-delay 5.25 \
  --send-at 6.00:0x7c:1:b90101b902b90280800001 \
  --trade-offer offer.hex --offer-after-open 8

Console: X → Poké Portal → Link Trade → offline, no code → search. A repeated --trade-offer offers one record per trade in the same seat. The wire-level requirements (identity message order, acknowledgement lowest_pending) are in Scarlet and Violet.

Legends Z-A

The Link Trade search alternates hosting and scanning, so pokeldn joins or hosts. Console: Link Trade → local communication → search with code 00000000. The joiner rescans until it takes a seat.

# host: start it first, then search on the console
POKELDN_RADIO=esp32:auto ./.venv/bin/python -u bin/za_host.py --keys prod.keys --trade-offer offer.bin --capture zh.jsonl
# join
./.venv/bin/python bin/za_join.py --channels 1,6,11 --dwell 0.35 --seconds 900 \
  --hold 450 --quiet-seat 25 --connect-timeout 6 --game --trade-offer offer.bin --offer-delay 4

Pick a Pokémon on the trade box and confirm when the other side's shows. Both roles answer another offer in the same session; a repeated --trade-offer queues records, one per trade. Back out with B when finished; the host closes when the console leaves. --offer-out FILE keeps what the console offered. For an emulated console over the LAN, use za_host.py --ip-host --our-ip IP --comm-id ffffffffffffffff and za_join.py --ip-join --host-ip IP --our-ip IP --comm-id ffffffffffffffff. The offer file is 354 bytes (nine-byte header, 344-byte record, one trailing byte); pokeldn.gen9.build composes the record because Z-A's layout is Scarlet's. See Legends Z-A.

Diagnostics

  • tools/ldn/ldn_scan.py lists discoverable LDN networks; esp32_sniff.py makes a second board an air sniffer for one MAC on one channel.
  • POKELDN_ESP32_TRACE=FILE records the board's serial traffic and counters; --capture FILE on every entry point writes the protocol trace as JSONL.
  • Host implementation: component boundaries, protocol flow, timing, shutdown.

Credits

License

AGPLv3

About

An ESP32 board as the radio speaks Nintendo Switch local wireless (LDN/Pia) to Pokémon games on a real Switch: trades and Mystery Gift with FRLG, LGPE, SwSh, BDSP, PLA, SV and PLZA.

Topics

Resources

Stars

68 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages