Joining a BDSP session, and what is under the encryption
This page covers a Brilliant Diamond or Shining Pearl Union Room session from the LDN advertisement to the Pia mesh: the keys, the Local Protocol, joining, hosting and leaving. Addresses are 1.3.0 main.
The advertisement
local_communication_id 0100000011d90000
scene_id 4352 (0x1100) Union Room; 5120 (0x1400) Union Room with a password;
12608 (0x3140) Grand Underground
version 4
accept_policy ALL, 1/8
application_data 17 bytes: Pia's 16-byte LDN header (ldn.md), then one byte
The comm id is Brilliant Diamond’s title id, shared by Shining Pearl (010018e011d92000). The advertisement decrypts with prod.keys alone (tools/ldn/ldn_scan.py).
The header’s CRC32 field (+0x04) is 0 in a plain room. A password room carries the CRC32 of the password’s ASCII digits there, little-endian (00000000 -> 0xC0088D03), and scene id 5120. A console filters on scene id at scan, skips rooms whose protection does not match its own, and before nn::ldn::Connect compares the CRC of its typed password with +0x04 (check 0x16b8890, error 0x6c51). No message carries the joiner’s CRC to the host: a wrong password sends the host nothing. A password longer than eight characters keeps eight for Pia and moves the rest into application data behind INL1. bin/bdsp_host.py --password 00000000 hosts a password room; bin/bdsp_connect.py joins one unchanged.
The passphrase
WirelessStrongCryptoKey2021
Used raw, 27 bytes, neither padded nor hashed (LDN accepts 16 to 64 bytes with an explicit length), as the nn::ldn::SecurityConfig passphrase; it never reaches Pia’s crypto.
Taking a seat
bin/bdsp_join.py scans, associates and reports the participant table; the console assigns the IP. The Union Room’s eight seats are the LDN max_participants. An LDN seat shows nothing on screen.
Connect failed with status code 1is a failed association; from the ESP32 board about one attempt in two fails. Retry before diagnosing.- Re-entering the room opens a new network (new channel, SSID and session parameter).
- A receiver on the LDN interface must filter its own source IP: broadcasts loop back.
- Unauthenticated Pia is dropped silently, with no loss of the seat.
Random matching
A console alone in its own Union Room session closes it and matches again, with no change on screen, until it has been alone long enough. INL1.IlcaNetSession$$GameState_GameFrontBefore_LocalRandom [0x02740640] runs on every session update, once per rendered frame at 30 frames a second. With one station it closes the session after 25 + (0x7F & r) updates (0.83 to 5.07 s, r random per attempt) until 270 updates alone (9.0 s) have accumulated, then keeps its network. The constants come from the IlcaNetSessionSetting constructor [0x01f46fc0]. Update chain: Sequencer$$SubscribeUpdate call [0x0202da38] -> _orderableList [0x01a8fed0] -> Sequencer$$Update [0x01a906d8..0x01a9071c] -> NetworkManager$$OnUpdate [0x02253190] -> IlcaNetSession$$Update [0x02030a38]; targetFrameRate 30 [0x01a8ed68], and the only quality level (Ultra) has vSyncCount 2 (30 fps at 60 Hz).
What is on the wire
Until a station acknowledges it, a hosting console broadcasts its update session to 169.254.x.255:12345 every 100 ms, dst_var = 0, 176 bytes:
32ab9864 89 00000000 11bac90d 0000 00 f5a83bd383ce712d 59baa5cbc320cb56 <144 bytes>
magic v dst=0 src pid f nonce (a counter) tag ciphertext
Version 9, encrypted: Pia 5.31-5.43. The header and transport protocols are on The Pia layer; pokeldn/ldn/pia5.py round-trips captured packets byte-identically.
The key hierarchy
cryptoKeyDataSeed 9918bd0fdcfa65779918bd0fdcfa6577 from global-metadata.dat
game key 9900bd0cdcfa65639918bd0fc7fa6577 = seed derived with version 199
session param 0x36dee059 advertisement +0x0c, little-endian
session key 7b182cb087eeabd228a2efd91a8be147 = AES-ECB(game key) over 16 SEAD bytes
network id b4c85cf8 advertisement +0x00, little-endian
source MAC 48:f1:eb:20:9b:22
crc32(netid||MAC) 0xda291352
IV (first packet) da29130df5a83bd383ce712d
Where the seed lives
INL1.IlcaNetSessionSetting’s constructor fills cryptoKeyDataSeed from a field-default blob in global-metadata.dat (<PrivateImplementationDetails>.33F804682DF9E210AABDC4D939CBCD380EC7517F); the method is on Reverse-engineering a Switch title.
The published key is the seed, derived
The game overwrites bytes 1, 3, 7 and 12 of the seed from the local communication version, 199 for 1.3.0 (the advertisement’s app_version); ldn_game_key(seed, 199) gives the game key.
The session key
seed = the session parameter (LocalProtocol+0x5b0)
state = for i in 1..4: prev = ((prev ^ (prev >> 30)) * 0x6C078965 + i)
rnd = four xorshift128 draws (shifts 11, 8, 19) -> 16 bytes, little-endian
key = AES-128-ECB(game key).encrypt(rnd)
pokeldn/ldn/sead.py implements SEAD’s RNG.
The GCM nonce
Built by nn::pia::local::LdnOutputStream::vfunc3 0x16b39c4:
IV[0..3] = u32be( crc32(network id LE || source MAC) )
IV[3] = overwritten with (packet.source_variable_id & 0xFF)
IV[4..11] = the eight-byte header nonce
The CRC is ordinary CRC32. The source MAC comes from the station record; the packet does not carry it.
The Local Protocol, decoded
The update session (Local Protocol 0x24, type 0x11), rebroadcast every 100 ms until every station acknowledges it:
local message header version 1, type 0x11, size 73
sequence id 4
network id 8b4a3b22 random, unrelated to the advertisement's
host variable id 11bac90d the packet header's source variable id
host constant id 0000 48f1 2022 9beb
allow participating 1
node 0..7 nine bytes each (ip, port, ranking); empty slots 0xff
host migration state 0
The host constant id, read little-endian, unpacks by the LDN rule (mac[2] << 56 | mac[4] << 48 | mac[5] << 40 | mac[3] << 32 | mac[1] << 24 | mac[0] << 16) to the host MAC. The Pia message header is big-endian, the Local Protocol’s fields little-endian, addresses inside them big-endian.
The ack
The 20-byte ack is on The Pia layer, sent as a broadcast with dst_var 0 and destination 0. The first ack stops the updates.
Joining the mesh
The handshakes are on The Pia layer. BDSP’s addresses:
| what | where |
|---|---|
| station protocol receive dispatcher | 0x0154e848, table 0x3e6b38f |
| connection request deserializer | 0x0154ebd0 |
| result mapping from internal errors | 0x0154f5e8 |
| protocol version lookup by id | 0x0159b850, 0 for an unregistered id |
| read a mesh message’s ack id | 0x01542db8 (size - 4, big-endian) |
| send the ack (8 bytes, on 0x14) | 0x01550324, MeshStationProtocol at session + 0xa0 |
| join REQUEST handler, host side | 0x0154b790, 0x0154b868 |
| join RESPONSE handler | 0x0154b984, 0x0154b9a4; pointer stored at MeshProtocol + 0x128 (0x0154e5c4) |
The console registers nine protocols (measured):
0x14 Station v2 0x18 Mesh v3 0x1c SyncClock v0
0x24 Local v0 0x58 RTT v3 0x68 Unreliable v1
0x7c Reliable v3 0x94 Session v1 0xa4 MonitoringData v0
The join response lists two stations, host index 0, joiner index 1, max_active 8. Result 7 means the variable id is already one of its stations. The Session Protocol (0x94) is inert on LDN: its handlers (dispatcher 0x15820b8, jump table 0x3e6b986) return because session+0x70 is null: LdnNetworkFactory slot 61 [0x16b30ac] builds no joint-session job. It is built at 0x157c66c (skipped when +0x38 behind GOT 0x4c4b850 is set), stored at session+0xC8, with one ReliableSlidingWindow per station [0x1581938; constructor 0x159dab4; 0x159de54(window, 2, 2, 0x94000001); receive slot 10 0x1581c98]. A valid reliable 0x94 is acknowledged, then dropped.
Hosting
A console entering the Union Room joins any room with a free seat before opening its own. bin/bdsp_host.py (pokeldn/bdsp/host.py) hosts one with scene id 0x1100, app_version 199 and a fresh network id and session parameter. What it sends, each rebuilt byte for byte from a retail host’s (tests/test_bdsp_host.py):
| step | message | framing |
|---|---|---|
| a station associates | update session every 100 ms until acknowledged, the joiner as node 1 | broadcast, dst_var 0 |
| its connection request | an accepted connection response, 949 bytes | dst_var 0, flags 0x01 |
| its mesh join request | the station ack, then the join response | dst_var 0 |
| the join response acknowledged | NetJoinData, reliable sequence 1 | dst_var the joiner’s |
| from then on | update mesh every second, RTT requests, NetCharacterStateData{0, 0} on 0x68 | dst_var 1 |
The host must answer the joiner’s Sync Clock (0x1C) requests with the request’s tick and the mesh clock in milliseconds. A joiner left unanswered deauthenticates after about ten seconds and re-associates while the screen shows “communication en cours”. Once answered, the room is symmetric and the trade runs unchanged.
Leaving
Both departures open on the mesh reliable window (0x18 port 1, sequence 1) and wait on an answer sent unreliably on port 0, which retail Pia sends twice. Mesh dispatcher 0x0154ac94.
| the console | sends | is owed |
|---|---|---|
| joiner | 04 <own index>, then station disconnection 03 on 0x14 | 08 <host index>, then 04 |
| host | 44 <host index> <new host index> to each station | 48 <station index> from every station |
Answered, a joiner deauthenticates within 0.2 s and a host closes its network within 0.5 s. Unanswered, a joiner retries for about 5 s and leaves after about 9 s; a host runs its migration for about 25 s, ending with Local Protocol 0x13 every 0.3 s for up to 10 s (LocalDestroyNetworkJob::WaitUntilAllClientsDisconnection 0x016b95b8), which a station leaving ends at once. Its steps: resend after 301 ms (job+0x68); cancel byte to WaitForCancel 0x016b9330; one station left (count 0x016b014c, test 0x016b9614) or 10000 ms since WaitUntilAllClientsReceiveUpdateSessionMessage 0x016b936c (job+0x70, test 0x016b9644) to StartDestroyNetwork 0x016b950c; the previous state ends on [[protocol+0x4f0]+0x5c] (0x016b0888) or after 10001 ms. Leave handlers: host’s leave request 0x0154b9e0 (answer via 0x0154c860), joiner’s response 0x0154baf8, migration response 0x0154b068; WaitLeaveResponse 0x0155fd2c, WaitMigrationResponse 0x015607bc.
pokeldn.bdsp.session.answer_departure builds both answers; bin/bdsp_host.py answers a leaving joiner, and bin/bdsp_connect.py answers a migration start and leaves the network on 0x13 (--no-leave-on-host-migration stays). Both launchers also leave as the console does: bin/bdsp_connect.py sends the leave request and the station disconnection when --hold ends or on a signal, and bin/bdsp_host.py sends 44 00 01 (HostSession.close) when --seconds ends or on Ctrl-C, then stops on the 48 or after 5 s. --hold-after-trade N takes the same path N seconds after the last queued trade, and never sooner than 30 s after it, when the player has control again (The completed trade). Leaving the trade box sends no departure message, only NetDataCurrentFlowCancelData{0} (0x25) and NetCharacterStateData{0} in one packet.
LAN mode
BDSP 1.3.0 never sets up LAN mode. Its Pia comes through Nintendo’s Unity plugin, whose framework initialisation (around 0x16886d0) builds the Lan factory (constructor 0x169fdcc) for its LAN mode, which needs a registered LAN setting. INL1.IlcaNetBase.PlatformInitialize registers the internet setting (0x1e13800) and PlatformInitialize2 the local one (0x1e139dc); nothing calls PiaPlugin.RegisterInitializeLanSetting (0x227caf0) or its native 0x227cac0. No C# name in global-metadata.dat mentions LAN.
Measurement methods
- The console answers a connection request only when the protocol count matches its own; sweeping the count gives 9. An unregistered protocol expects version 0, so bisecting the version reads any protocol’s.
- The signal must be a reply; silence is as often a lost packet. A reliable window acknowledges data it accepts, so send data and sweep only the sequence id.