A computer speaking Nintendo Switch local wireless (LDN) to Pokémon games running on a real Switch / Switch 2.
Two kinds of target share one wireless layer:
- FireRed and LeafGreen: a GBA ROM inside the Switch's emulator. Trading, Mystery Gift, Union Room battles and native code on the console all work end to end on retail hardware.
- Native Switch titles: Brilliant Diamond / Shining Pearl and Sword / Shield. Both have completed a trade with a retail console: an invented character walks in a BDSP Union Room and the game runs its own trade flow against it; a Sword accepted a Pokémon, gave one of its own and wrote its save.
The full protocol documentation is at decryptu.github.io/pokeldn.
The package is layered by what a module is true of: pokeldn.ldn is the wireless layer every Switch
title shares, pokeldn.gba is the GBA wireless adapter's protocol above it, and pokeldn.frlg,
pokeldn.bdsp and pokeldn.swsh are the games. Entry points are named for the game they drive.
This project basically exists to prove that it can be done. From here, I'm hoping the community takes notice so that we can get things like an unofficial GTS and online battling going. It should serve as a pretty good reference for anyone interested in pursuing these goals or anything else related to multiplayer within these games. AI tools were used to reverse engineer the protocol and to write parts of the code. If you'd like to contribute to the effort, join the Discord!
demonstration.mp4
This demo was recorded using the ALFA AWUS036ACHM. The RZ616 is half as fast on average and sometimes deadlocks before gracefully exiting.
- End-to-end trading with a real game on a real Switch, in both directions
.pk3/.ek3input and output- Mystery Gift distribution: Wonder Cards with scripted deliveryman gifts, Wonder News, and a visiting Battle Tower trainer
- Union Room: greetings, trading-board trades, live chat, and full link battles
- Native code on the console through the gift link: reading and writing its save, mapping its ROM, and calling its own functions
- Native Switch titles: LDN discovery, association and a completed trade against Brilliant Diamond / Shining Pearl and Sword / Shield, plus an offline toolkit for reading a retail title's own code
- Linux
- Python 3.11+, and a venv with
requirements.txtinstalled - A compatible Wi-Fi card (see below)
- A Switch or Switch 2 with FRLG, played to the point where the Direct Corner has been unlocked (about 20 to 40 minutes)
- At least 2
.pk3files to serve as party members / trade fodder - Switch
prod.keys(default location~/.switch/prod.keys)
The LDN implementation in vendor/LDN is installed by pip install -r requirements.txt.
The PyPI ldn package of the same version lacks the adapter compatibility fixes; do not substitute it.
| model | type | driver | reliability |
|---|---|---|---|
TP-Link Archer T3U (2357:012d) |
external | rtw88_8822bu |
high |
| ALFA AWUS036ACHM | external | mt76x0u |
high |
| Realtek RTL8821CE | internal PCIe | rtw88_8821ce |
high |
| AMD RZ616 | internal M.2 | mt7921e |
low |
Known problematic: Intel AX200 (iwlwifi) and Atheros AR9271 (ath9k_htc) cannot be assigned an IP.
See Adapters for the configuration each one needs.
-
Create a Python venv and install
requirements.txt. -
Keep NetworkManager away from the LDN interfaces. Marking the Wi-Fi card unmanaged is not enough: a join creates a fresh
ldnclientinterface mid-run, NetworkManager grabs it and points wpa_supplicant at it, and the join fails with[Errno 114] Match already configured. Exclude the LDN interfaces by name:# /etc/NetworkManager/conf.d/zz-ldn-unmanaged.conf [keyfile] unmanaged-devices=interface-name:ldnclient;interface-name:ldn;interface-name:ldn-mon;interface-name:ldn-tapthen
sudo systemctl restart NetworkManager. The file must sort last (zz-*): some distros ship a later-sorting file that setsunmanaged-devices=noneand overrides it. Verify withNetworkManager --print-config | grep unmanaged. -
Ensure you can become root. The entry points require it.
bin/ |
what you run against a console. FireRed/LeafGreen: frlg_mg_host.py (Mystery Gift, Wonder News and native code), frlg_mg_client.py (receive a card from a console), frlg_trade_host.py (trade and Union Room host), frlg_trade_join.py (trade joiner). Native titles: bdsp_join.py, bdsp_pia_probe.py, swsh_join.py, swsh_connect.py |
tools/ldn/ |
the radio, for any target: ldn_scan.py, sniff.py, joyspot_probe.py, ldn_debug_report.sh |
tools/frlg/ |
reading what a FireRed console sent back, offline: dump_read.py, script_read.py, rom_functions.py, cartridge_pair.py, game_data_read.py, english_build.py |
tools/switch/ |
reading a retail Switch title's own code, offline: xci_read.py, romfs_read.py, nso_read.py, nso_relocs.py, nso_imports.py, rtti_names.py, arm64_xref.py, arm64_dis.py |
pokeldn/ |
the package everything above is made of: ldn/ the wireless layer, gba/ the GBA link above it, frlg/ bdsp/ swsh/ the games, gen8.py the entity format the two native titles share |
asm/ |
ARM sources for the payloads the console runs; scripts/gen_buffer_scripts.py assembles them into pokeldn/frlg/rom/buffer_payloads.py |
scripts/ |
setup, deployment and code generation; never pointed at a console |
config/ |
host profiles (host.toml, and host.local.toml for this machine) |
docs/ |
the protocol findings, each with its citations; published at decryptu.github.io/pokeldn |
tests/ |
python -m pytest tests/ -q |
vendor/ |
the bundled LDN implementation and the mt7601u AP-mode driver |
Run the entry points from the repo root: sudo -E ./.venv/bin/python -u bin/frlg_mg_host.py .... They
put the root on sys.path themselves; config files and default output paths resolve against the
working directory.
sudo -E ./.venv/bin/python bin/frlg_trade_join.py --live -o output.pk3 PARTY1.pk3 PARTY2.pk3sudo -E ./.venv/bin/python bin/frlg_trade_host.py -o output.pk3 PARTY1.pk3 PARTY2.pk3Linux advertises the group and acts as the trade leader. With the default settings it offers the
second supplied party member (PARTY2.pk3) and writes the Pokémon received from the Switch to
output.pk3. Host defaults come from config/host.toml, then the optional ignored
config/host.local.toml; command-line flags override both.
bin/frlg_trade_host.py --print-effective-config inspects the resolved profile without root or Wi-Fi
hardware.
Then:
- Run the host command and wait for
Hosting Direct Corner. - On the Switch, enter the Direct Corner and choose Join Group.
- Select the Linux trainer and join. The leader performs its room-entry route automatically; wait until the host reports that trade selection is active.
- On the Switch, select the Pokémon to trade away and accept the confirmation.
- After the trade and save sequence returns to the trade menu, wait for the host prompt, then select CANCEL and confirm YES.
- Allow the automated room exit and disconnect to finish. The received Pokémon is saved to
output.pk3(or the path passed to--out).
Some optional flags:
| flag | options | purpose |
|---|---|---|
--verbose |
verbose protocol output | |
--phy |
phy name, e.g. phy1 |
Wi-Fi PHY selection |
--keys |
/path/to/prod.keys |
non-default prod.keys location |
--slot |
zero-based party index | host party member offered in the trade |
--capture |
output path | JSONL diagnostic capture |
--config |
TOML path | replace the tracked shared host profile |
--local-config / --no-local-config |
TOML path / | select or disable the machine-local layer |
--print-effective-config |
print the redacted resolved profile and exit | |
--skip-encryption |
delegate transmit CCMP to mac80211/hardware; traffic stays encrypted over the air | |
--accept-decrypted-ccmp |
accept driver-decrypted RX plaintext with retained CCMP metadata | |
--ot |
Gen III trainer name | override the default trainer name for this run |
--version |
firered or leafgreen |
override the configured game version |
--id |
decimal TID[:SID] |
override the trainer ID, and optionally the secret ID |
--help is the authoritative list for each entry point.
--union-room advertises on the middle NPC's path instead of the Direct Corner's; the console applies
a different accept list.
sudo -E ./.venv/bin/python bin/frlg_trade_host.py --union-room --union-room-keepalive 120 \
PARTY1.pk3 PARTY2.pk3The console takes about ten seconds to appear to itself as connected; the wait is the RFU library's.
Add --board-type normal to register the offered Pokémon on the trading board,
--union-room-chat with --chat-message or --chat-file for chat, and
--union-room-battle --battle-fight for a link battle. In a battle the console elects itself master
and computes everything; the host answers its controller commands. The console needs two non-egg
Pokémon at level 30 or lower in its own party or it refuses on its own screen.
See The link protocol for the connect sequence, the activity bytes and the link buffer protocol.
All three entry points start from DEFAULT_TRAINER in pokeldn/config.py. Use
--ot, --version and --id for per-run overrides; the ID format is decimal TID[:SID]:
# Set TID to 12345 and retain DEFAULT_TRAINER.sid
./.venv/bin/python bin/frlg_trade_join.py --live --id=12345 PARTY1.pk3 PARTY2.pk3
# Set TID to 12345 and SID to 34567 while hosting
sudo -E ./.venv/bin/python bin/frlg_trade_host.py --live --id=12345:34567 PARTY1.pk3 PARTY2.pk3Each component must be between 0 and 65535, and the resulting 32-bit LinkPlayer ID is
(SID << 16) | TID. The resolved profile is used consistently by discovery, Pia Session, RFU game
data, LinkPlayer and trainer-card identity. Edit DEFAULT_TRAINER for gender, language, National Dex
or game-completion defaults that have no CLI flag.
bin/frlg_mg_host.py advertises on the Friend path and sends a Wonder Card plus a delivery RAM
script. The default payload is the repeatable legendary-beast cutscene.
sudo -E ./.venv/bin/python -u bin/frlg_mg_host.py \
--gift beast-cutscene --flag-id 1005 \
--capture mystery-gift.jsonlOn the Switch choose Mystery Gift → Wonder Cards → Friend, then select the Linux host. The save
must already have Mystery Gift unlocked. --make-artifact writes a .ram.lst audit listing of the
exact card and delivery-script bytes a run sent.
The beast depends on the receiving save's starter: Bulbasaur gives Suicune, Squirtle gives Entei,
Charmander gives Raikou. The live host also distributes the two halves of a shared Stamp Rally card
(--gift solrock-stamp and --gift lunatone-stamp, in either order).
See Mystery Gift for the protocol flow, the gift catalogue, the authoring system, and the Friend path requirement.
The console's Mystery Gift menu has a second column, and --news serves it. Wonder News is 444 bytes
of title and body with no flag ID, no delivery script and no gift attached: the reward is a berry from
the man in the house in Cerulean City. On the Switch choose Mystery Gift → Wonder News → Friend. A
Wonder Card host is not listed on that screen, and vice versa.
sudo -E ./.venv/bin/python -u bin/frlg_mg_host.py --news
sudo -E ./.venv/bin/python -u bin/frlg_mg_host.py --news berry --news-id 7A console keeps news only when it differs from what it already holds; --news-id N makes the same
text land again.
A Mystery Gift session can run native ARM code on the console and read its live save back, including the secret ID and every party Pokémon's PID, IVs and nature.
sudo -E ./.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 sav2The console stays on its Mystery Gift menu, nothing is written and no Wonder Card changes hands. See Code on the console for the mechanism and the other payloads.
Discovery needs only prod.keys; association additionally needs the title's LDN passphrase, which
bin/bdsp_join.py and bin/swsh_join.py already carry.
Brilliant Diamond / Shining Pearl. On the console: any Pokémon Center → 2F → the left attendant → the plain "yes" (not the password or group option), then wait in the Union Room.
# see the session without joining it
sudo -E ./.venv/bin/python tools/ldn/ldn_scan.py --channels 1,6,11,36,40,44,48 --dwell 0.8
# associate and hold a seat, logging everything the advertisement says
sudo -E ./.venv/bin/python bin/bdsp_join.py --channels 6 --hold 90A successful join prints the participant table with the console as participant 0 and this machine as participant 1. Nothing happens on the console's screen: LDN association is below the game.
Sword / Shield. Its local communication id is filled at runtime, so the first run is a scan:
# on the console: Y-Comm -> Link Trade over LOCAL communication, which puts it on the air
sudo -E ./.venv/bin/python bin/swsh_join.py --scan-onlyPoint the scan at a screen where the console hosts. On the Mystery Gift local-wireless screen it is
a receiver searching and has nothing to advertise. Everything each advertisement carries is written
to scratchpad/swsh_net_facts.json; once the id is known, --comm-id <hex> joins it.
tools/ldn/ldn_scan.pyprints discoverable LDN networks and decoded FRLG application data.tools/ldn/sniff.pycaptures advertisement and management traffic from a monitor-capable radio.tools/ldn/ldn_debug_report.shrecords local radio, interface, route and NetworkManager state.--capture FILEon either host writes the protocol trace as JSONL.
See Host implementation for the component boundaries, protocol flow, timing ownership and shutdown sequence.
- kinnay: the LDN library this is built upon, and the NintendoClients wiki
- pokefirered: a full decompilation of FireRed/LeafGreen, including the Switch port
AGPLv3