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.
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.
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 isuucpon Arch). - From source:
pip install -r gui/requirements.txt, thenpython gui/main.py; the Pokemon builder needsdotnet build -c Release services/pkhex(.NET 10 SDK), and file drops need the clientpython scripts/build_client.pybuilds (Flutter).python scripts/pack_app.pybuilds the app for the current OS intodist/, with firmware and that client required. See desktop builds. python -m pokeldn --listlists 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.
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.pokegiftBoth launchers accept --gift-file FILE. Native conversion and the file schema are in
Mystery Gift files.
- 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/esp32for 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.txtinstalled. No root. The bundledvendor/LDNis installed by it; do not substitute the PyPIldnpackage. - 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
.pk3party members. - Switch
prod.keys(default~/.switch/prod.keys;--keys PATHelsewhere, absolute undersudo).
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:autoPORT 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.
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.
./.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 tradeThe 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.
- Run the host and wait for
Hosting Direct Corner. - On the Switch: Direct Corner, Join Group, pick pokeldn's trainer. Wait until the host reports that trade selection is active.
- Select the Pokémon to trade away and confirm.
- 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.pk3Mystery 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.jsonlThe 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 7Console 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-unsafeThe 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.pb7Console: Communiquer, Communication locale, Échange, link code Pikachu ×3, wait on the search screen. See Let's Go.
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 900The 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.
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 0x2B7F4C12Association 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.
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 radioConsole: 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.
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 8Console: 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.
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 4Pick 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.
tools/ldn/ldn_scan.pylists discoverable LDN networks;esp32_sniff.pymakes a second board an air sniffer for one MAC on one channel.POKELDN_ESP32_TRACE=FILErecords the board's serial traffic and counters;--capture FILEon every entry point writes the protocol trace as JSONL.- Host implementation: component boundaries, protocol flow, timing, shutdown.
- kinnay: the LDN library this builds on, and the NintendoClients wiki
- pokefirered: decompilation of FireRed/LeafGreen, including the Switch port
AGPLv3

