BDSP’s own protocol, and controlling a character
BDSP carries a typed message protocol inside its Pia payloads. Dpr.NetworkUtils.NetDataParser in TeamLumi/opendpr (a decompiled C# recreation) lists every message: an ANetData<T> with a one-byte DataID around a plain struct T. Addresses marked 1.3.0 main are in Shining Pearl’s image.
Framing
0x0 1 data id
0x1 2 payload length, big-endian
0x3 . the struct, little-endian, packed
JoinData (byte, byte, byte, short, Vector3) is 17 bytes on the wire.
NetDataParser registers 65 messages, all in pokeldn/bdsp/netdata.py (generated by scripts/gen_bdsp_netdata.py). The source fixes 53 layouts; twelve carry a C# string, array or list (netdata.OPAQUE) and the binary fixes them: a payload is the marshalled struct (ANetData<T>.ConvertStructToBytes [main.bin 0x27bb0e0]), strings and arrays as fixed-size fields sized by Il2CppTypeDefinitionSizes and fieldMarshaledSizes, written by marshalToNative in CodeRegistration.interopData [1.3.0 main 0x4acd0c8]. Captured payloads match (room.NATIVE_SIZES, room.MEASURED).
| id | payload | bytes | layout |
|---|---|---|---|
| 0x02 | PosListData | 72 | 12 x PosData (ushort posX, ushort posZ, short rotY) |
| 0x13 | TradePokeData | 328 | one encrypted PB8 at stored size |
| 0x14 | NetRecodeData | 694 | RECORD 120, RANDOM_SEED 132, TvRecodeData 204, 4 x TV_STR_DATA 36, RECORD_HEAD 48, ten ints, six bytes |
| 0x15 | BallDecoData | 143 | affixSealCount, Is3DEditMode, IsAppliedTemplate, then 20 x SealParam{short x, y, z; byte id} |
| 0x18, 0x54 | UgSecretBase | 616 | short zoneID, posX, posY; byte direction, expansionStatus; int goodCount; 30 x UgStoneStatue 20; bool isEnable (4) |
| 0x22 | StanbyListData | 20 | 5 x StandbyData (isAddPlayer, hostIndex, myIndex, langId) |
| 0x24 | TradeTranerData | 32 | 13 UTF-16 chars, uint tranerId, byte cassetVersion, byte langId |
| 0x29, 0x61 | UgStationID_to_DigFossilIDList | 8 | byte DigFossilIDs[8], a permutation of 0..7 |
| 0x38 | BattleMatchingPokeData | 481 | a 328-byte PB8, 20 x SealParam 7, uint attachPokemonId, uint attachPersonalRnd, byte index, num, is3DEditMode, isAppliedTemplate, affixSealCount |
| 0x42 | NetPlayerName | 28 | 13 UTF-16 chars, byte genderid, byte languageId |
SendStandbyPlayerData [1.3.0 main 0x01e52fa0] fills 0x22 from unionMatchWaitDataList; an empty list is twenty zero bytes. A station adds itself with NetDataStandbyWaitData (0x59, same four bytes, isAddPlayer = 0 removes it): after 59 0004 01 00 01 03 the next 0x22 is 22 0014 01 00 01 03 and sixteen zeros. The screen does not change.
The marshaller does not clear its buffer: a fixed field carries heap residue past its value (the trading page).
What has been on the air
A console alone in the room sends NetJoinData (0x01, reliable, 17 bytes), NetPosData (0x02, unreliable, 72), NetRequestData (0x12, 1) and NetDataIsMatchWaitData (0x23, reliable, 1). On request it also sends 0x09 and 0x22; on an activity, 0x14 and 0x15. Trade messages are on the trading page. A 0x23 can sit behind a presence byte 0x00; a walk must take 0x00 as a message (Message framing).
The messages the console repeats
NetJoinData is the whole of JoinData:
| offset | size | field |
|---|---|---|
| 0x00 | 1 | avatarId (The model) |
| 0x01 | 1 | colorId, 0 |
| 0x02 | 1 | cassetVersion, 0x31 |
| 0x03 | 2 | InitRotY, degrees, little-endian, unaligned |
| 0x05 | 12 | InitPos, three little-endian floats x, y, z |
NetRequestData is one byte, the id wanted. The console requests 0x23 repeatedly until the client acks its reliable window, and requests NetCharacterStateData (0x04) when it creates a character: a 0x04 request means a character exists, and bin/bdsp_connect.py prints it as the verdict.
UnionRoomManager$$SetNetData [1.3.0 main 0x01e50700] answers a request for six ids and ignores the rest:
| requested | the console sends |
|---|---|
0x01 NetJoinData | its join record |
0x04 NetCharacterStateData | its character state |
0x09 NetDataBattleTypeData | its battle rule |
0x13 NetTradePokeData | its offered Pokemon; nothing when none is selected |
0x22 NetDataStandbyWaitListData | its standby list |
0x23 NetDataIsMatchWaitData | whether it waits to be matched |
Answers come on the reliable stream within 30 to 130 ms, to a station with a character in the room.
Compression
Reliable header flag 0x10 marks a zlib stream of the whole game message, header included. ReliableSlidingWindow’s push [1.3.0 main 0x15a10d0] deflates when the window’s switch (+0x7ac) is on, and sends raw when the result is not smaller (0x159a5b0). The bytes equal Python’s zlib.compressobj(5, zlib.DEFLATED, 12, 5) with a sync flush then a finish. Joins go raw; records (0x14), capsules (0x15) and the all-zero 0x22 shrink. bin/bdsp_connect.py inflates on the flag.
Senders of the other opaque messages
| id | class | sent by |
|---|---|---|
| 0x14 | NetDataRecodeData | RecodeMatching$$SendRecodeData, UnionStateController$$SendRecodeData |
| 0x15 | NetDataAttachSealNetData | BallDecoMatching$$SendBallDecoData |
| 0x18 | NetSecretBaseData | UgNetworkManager$$SendMySecretBaseData; on request |
| 0x61 | NetDigTableData | on request |
| 0x38 | NetDataBattleMatchingSelectPokemon | BattleMatchingManager$$SendSelectPokemonData |
| 0x42 | NetPlayerNameData | UgNetworkManager$$SendOnJoinNewPlayer, SendPlayerNameData |
| 0x54 | NetSecretBaseUpdate | no reliable sender |
The unreliable stream
| bytes | message |
|---|---|
04 0002 00 00 | NetCharacterStateData{NONE, 0}, every two seconds |
12 0001 04 | NetRequestData for 0x04 |
02 0048 <72 B> | NetPosData while the avatar walks |
room.build_state() and room.build_match_wait(False) build the state and the 0x23 answer. The console retransmits a reliable message five times a second until acked; an ack two beyond its last sequence is ignored.
Sending messages the game acts on
The reliable sequence id is shared with the console’s sends; a message below the acked id is dropped in silence. Read the id immediately before each send. A retail Shining Pearl acted on a reply under the id of its own last message and dropped one under an id below it: after its state message (27) and its Pokemon (28), a returned Pokemon under 27 was acknowledged and never checked. A join at the acknowledged sequence is acted on; --room-pattern fixed stops at the first 0x04 request, --join-wait seconds apart (default 1.0).
UnionOpcManager calls CreateCharacter(joinData) on each join: forty joins are forty arrivals. A character with no session behind it follows the player across maps until the game restarts. OpcManager$$RemoveCharacter [0x02279ee4] takes a station index.
The character record
OpcManager.CharaData is {int stationIndex, string assetName, int colorId, int avatarId, int sexId, int cassetVersion}, keyed by station.
The model
NetJoinData.avatarId -> CreateCharaData -> CharaData.avatarId
-> UnionCharacterTable.SheetSheet1{ID, AssetName}
-> OpLoadCharacter("persons/field/" + assetName)
CreateCharaData [1.3.0 main 0x022796b0] sets sexId = avatarId > 7 && avatarId != 16: 8 to 15 a girl, 0 to 7 and 16 a boy. 8 is a girl, 0 a boy in a blue cap (--join-avatar N).
A console’s own join carries work 360 (GetAvaterId [0x01e54030]). A new save holds 0. The appearance counter (ev_union_character_select_*) stores 16 or 1 to 7 for a boy, 8 to 15 for a girl; otherwise, while work 360 is 0, ev_traner_other_see_random sets it to 1 + a random value below 16, either sex. The join therefore names the save’s sex only after the counter was used.
NetDataTranerCardData (0x05, 75 bytes) draws nothing in the Union Room.
The state byte
StateData is {byte state, byte isRecruiment}, state an OpcState.OnlineState:
| value | name | value | name |
|---|---|---|---|
| 0 | NONE | 5 | RECRUITMENT_RECORD |
| 1 | DIG_FOSILL | 6 | RECRUITMENT_GREETINGS |
| 2 | SECRETBASE_ACTION | 7 | RECRUITMENT_BALL_DECORATION |
| 3 | RECRUITMENT_BATTLE | 8 | COMMUNICATE |
| 4 | RECRUITMENT_TRADE |
17 to 21 are the NOW_* states (the transitionType table). A RECRUITMENT_* value raises a speech bubble: StateData{RECRUITMENT_TRADE, 1} shows a trade bubble.
A received 0x04 reaches UnionOpcController$$SetNetData [1.3.0 main 0x01e48c50] (dropped when the station has no character), which sets the state, picks the host or normal emoticon by isRecruiment, and plays animation 0 for a non-zero state. A remote character’s state changes only on a 0x04 from its own station or on removal. Until the character’s Start runs, the setter drops the value [0x02277c50], and the 0x04 request goes out before that [0x01e49854]: repeat the state every two seconds, as the console does.
OnlinePlayerCharacter$$IsCanTalkState [0x02277af0]: states 1, 3 to 8 and 17 to 21 can be talked to.
Walking
The console’s own NetPosData comes every 0.410 s spanning 0.935 units (room.POS_PERIOD, room.POS_STRIDE). pos = (-posX * 0.05, posZ * 0.05). A remote character collides with walls; the player does not collide with it. --room-walk-steps 8 stays inside the room; a trade needs no walk (--room-walk-steps 0, the trade path’s default).
Being talked to
A player with an emote up stays in place until approached, and their A press does nothing while their state is non-zero. The trade emote is NetCharacterStateData{4, 1}, cleared by {0, 0}. Gate on isRecruiment: state 18 is a console already in a trade.
The approach is NetDataTalkReserveData (0x63), 63 00 01 00 (bin/bdsp_connect.py --initiate-talk; bin/bdsp_host.py --approach-delay). The console approaching the client:
cli -> 64 0003 00 01 04 NetDataTalkReserveResultData{IsCanTalk, IsRecruitment, emoticonStateType}
con -> 06 0005 00 01000000 NetDataTalkData{talkOpcSexId: 0, talkState: GREETING}
con -> 10 0002 01 04 NetDataTalkCancelEndData{IsRecruitment: 1, emoticonStateType: 4}
Without the 0x64 the player stays frozen. IsCanTalk 0 runs the greeting and parks it on “one second!”; 1 shows “sorry, I have other plans”. Dropping the station releases a parked greeting.
The console approaching
UnionRoomManager$$MyUpdate [1.3.0 main 0x01e49fb0] handles A only when the player’s state is 0, no talk or window is open and the player is outside every EnterCollision. For the nearest character within 10.0 units:
| the character’s state | what A does |
|---|---|
| 0, 2, 9 to 16 | nothing |
| 8, 17 to 21 | StartTalk(opc, 0, 0, 0) [0x01e4c2d0] |
| 4 | CheckErrorMessageTrade, then as the last row |
| 7 | CheckBallDeco, then as the last row |
| 1, 3, 5, 6 | sends 0x63, stores the state in nowTalkReserveState (+0x188), CreateSelectStateModel(state, 1) |
The console reading a 0x64 [0x01e50dec] sends NetDataTalkCancelEndData{0, 0} unless emoticonStateType equals nowTalkReserveState, which it then clears; so a character advertising {4, 1} answers with 64 0003 00 01 04, once per 0x63, ignoring retransmitted copies.
With the console as the talker, the client’s character is the recruiter. A recruiter’s yes is NetDataTransitionData{18, 0}, 07 0002 12 00 (the trading page). Sent after the 0x64, it starts the trade on the talking console with no yes from its player (SwitchTransitionMessage [0x01e53bf0] -> TransitionTradePoke [0x01e5c1c0]): the console sends its 0x24 and opens the trade box. SwitchTransitionMessage reads the type’s model with no null test; a 0x07 to a console that has not pressed A on a trade recruiter since entering reads through null.
The name in the greeting
The greeting names a character by its station’s Pia player name (NetworkManager$$GetGamerData [1.3.0 main 0x02250e50]), decoded as UTF-8 from PlayerInfo. Utils$$CheckNGTrainerName [0x01cbdc30] substitutes a version name when the name is empty, flagged by CheckNgWords, or longer in UTF-16 units than the language’s limit:
lang | limit |
|---|---|
| 1, 8, 9, 10 | 6 |
| any other positive value | 12 |
| 0 or below | the UI’s current language decides |
PlayerInfo is 195 bytes (parser [0x0154f044..0x0154f5e0]):
| offset | field |
|---|---|
| 0x00 | name encoding: 2 means 20 UTF-16 units, anything else 80 bytes |
| 0x01..0x50 | name |
| 0x51 | second string’s encoding |
| 0x52..0x79 | second string, 40 bytes |
| 0x7A | language, a MsgLangId |
| 0x7B..0xBA | 64 bytes |
| 0xBB..0xC2 | u64 |
MsgLangId JPN 1, USA 2, FRA 3, ITA 4, DEU 5, ESP 7, KOR 8, SCH 9, TCH 10
A console sends its game text language (save CONFIG.msg_lang_id, get_msgLangID [0x0237e100]) at 0x7A and its checked name, encoding 1. The station protocol’s writer places it [0x01550e98]. bin/bdsp_connect.py and bin/bdsp_host.py build the PlayerInfo with pokeldn/bdsp/host.py player_info and send --language, default 3; the desktop app passes its trainer language. Under 3 an 11-character name shows whole. Under 1 a retail French Shining Pearl showed an 11-character name as “Perlo.” with the greeting “Konnichiwa!”.
The substitute depends on byte 2 of the talked-to character’s NetJoinData: label 247 of dp_characters for 0x31, label 206 otherwise, plus a full stop:
| bundle | 0x31 (Shining Pearl) | anything else |
|---|---|---|
| english | Pearl. | Diamond. |
| french | Perlo. | Diamant. |
| german | Perl. | Diamant. |
| italian | Perl. | Diaman. |
| spanish | Perla. | Diamant. |
| jpn, jpn_kanji | パール. | ダイヤ. |
| korean | 펄. | 다이아몬드. |
| simp_chinese | 帕尔. | 戴亚. |
| trad_chinese | 帕爾. | 戴亞. |
talkState, and the value that crashes the game
TalkState is {CHECK = 0, GREETING = 1, NONE = 2}. CHECK makes UnionStateController$$SwitchSpokenStateMine dereference msgWindow with no null guard (0x1fd5ec0); a player standing with an emote up has none, so CHECK crashes the game. pokeldn/bdsp/room.py refuses to send it.
NetDataTransitionData{transitionType, isRecruitment} (0x07) advances a parked greeting into an activity. It is sent as a tail call, invisible to a BL-only caller scan (finding callers). SwitchTransition [1.3.0 main 0x01e5ba70] dispatches on it:
| transitionType | activity | entered through | model at UnionStateController + |
|---|---|---|---|
| 3, 17 | battle | TransitionBattle | 0x38 or 0x40 by isRecruitment |
| 4, 18 | trade | TransitionTradePoke | 0x50 |
| 5, 19 | record mixing | RecodeMatching$$Open | 0x60 |
| 6, 20 | trainer card | TransitionShowTrainerCard | 0x70 |
| 7, 21 | ball capsules | BallDecoMatching$$Open | 0x80 |
| 8 to 16 | none |
RecodeMatching$$Open and BallDecoMatching$$Open send the console’s own record at once and wait; only the partner’s writes the save.
Record mixing
The console recruits with “Échanger des données” (state 5). On its player’s yes it sends NetDataTransitionData{5, 0}, then its 694-byte NetDataRecodeData (zlib), and waits at state 19. Unanswered, it returns to the room after about 44 s. room.parse returns the record’s head as recode:
RECORD.record[30] thirty uint counters indexed by RECORD_ID
RANDOM_SEED group_name (16 chars), name (32 chars), int sex, int region_code,
ulong seed, ulong random, long time_stmp (FILETIME), int user_id
TvRecodeData five TV records, each `bool isEmpty` (4 bytes), ints, a TV_STR_DATA
4 x TV_STR_DATA {16-char value, byte language, genderId, two reserved}
RECORD_HEAD 13-char username, int language, byte sex, int body_type, uint uniqueID
ten ints, six bytes per-TV branch values, myVersion (0x31), five *IsNotEmpty flags
Unwritten fields hold heap pointers. RECORD_HEAD.sex and RANDOM_SEED.sex can disagree.
The battle ladder
A battle (state 3 while recruiting) runs a ladder; the recruiting console waits on the joiner at every rung:
| the joiner sends | the console does |
|---|---|
NetDataTalkData{GREETING} | shows “Un combat ? OK !” and waits |
NetDataSelectData{0} (0x08) | shows “… est en train de choisir quoi faire…” and waits |
NetDataBattleTypeData{0} (0x09, Single) | asks its player; on yes sends NetDataTransitionData{17, 0} and opens the lobby |
NetDataBattleMatchingJoin (0x30) {uint id, byte stationIndex, index, language, colorId, avatarId, sexId, cassetVersion} | answers with its own join and relays the joiner’s; the joiner appears in the second slot |
NetDataBattleMatchingReady (0x32, empty) | NetDataBattleMatchingState{0, 6} (0x33, SelectBattleTeam) |
| nothing | on the team choice, six 0x38, then “en attente d’autres personnes” |
Each 0x38 holds an encrypted PB8 (the picked team in order), residue seals with affixSealCount 0, index 0 to 5 and num 6. No check on the 0x30 id is located. MatchingState: None 0, Initialize 1, Load 2, RecruitmentMember 3, SelectTeamMember 4, SelectRule 5, SelectBattleTeam 6, SelectPokemon 7, GoBattle 8, Result 9, Resume 10, Closing 11, LeavedOtherMembers 12.
NetDataSelectData (0x08) is {byte index}; its receiver [0x01e52a30] ignores the index and drives UnionStateController+0x38 (the battle recruitment model) to BATTLE_RULE_SELECT_WAIT whatever conversation is open, with no null test. That model is set only when the player recruits a battle and starts null on every Union Room visit (the UnionRoomManager is destroyed on leaving the room’s zones, NetUseManager.OnZoneChange [0x026cfef0]). By that reading a 0x08 to a console that has not recruited a battle in this visit stores to address 0x18 [0x023e2604]. A retail Shining Pearl that had recruited none acknowledged 08 00 01 00 on its trade box and on the trainer-card exchange, and its screen did not change; whether the receiver ran is unknown. Never send 0x08 unless the console’s own 0x04 says state 3 with isRecruiment 1.
The Grand Underground
The Underground advertises the same local_communication_id under scene id 12608; the same session line associates, with no join record (--room-walk 0). On a station’s arrival UgNetworkManager sends:
| id | class | bytes | content |
|---|---|---|---|
| 0x17 | NetZoneData | 16 | Vector3 pos, int zoneID |
| 0x42 | NetPlayerNameData | 28 | 13 UTF-16 chars, byte genderid, byte languageId |
| 0x50 | NetKousekiCount | 4 | int Value |
| 0x41 | NetSecretBaseInfo | 16 | instead of 0x17 inside a secret base: Vector3 pos, int zoneID of the entrance |
A NetUgJoinData (0x16, 18 bytes: byte avatarId, colorId; short zoneID, InitRotY; Vector3 InitPos) naming the player’s zone puts a character next to the player; the console requests its 0x04 and NetNaminoriData (0x55) and sends NetPosData every 0.41 s (--ug-join --room-walk-steps N).
UgNetworkManager$$OnReceiveRequestData [1.3.0 main 0x01f7c050] answers requests for 0x01, 0x04, 0x18, 0x19, 0x54, 0x55 and 0x61, once the console’s window has carried something:
| requested | bytes | content |
|---|---|---|
| 0x18, 0x54 | 616 | the player’s own UgSecretBase; nothing when its zoneID is 0 |
0x61 NetDigTableData | 8 | the dig-fossil table, a permutation of 0..7, once it is ready |
A non-host station requests 0x61 on joining. OnReceiveDigTableData [0x01f7dad0] adopts the first 0x61 that arrives while its table flag (+0xA8) is 0, from any station and unchecked, rebuilds the dig points and sets the flag; later ones are ignored. CreateDigPoints indexes the table by MyStationIndex and looks up that group ID with no null test: send only permutations of 0..7, as a larger byte faults the console. Each UgFieldManager session builds a new UgNetworkManager, so each adopts a table afresh. 0x29 NetDigGroupIdData has no sender and no receiver branch.
bin/bdsp_connect.py --inject-file PATH sends each new ID:HEX line of the file reliably.
Ball capsules
The console recruits with “Déco Capsule” (state 7). It sends NetDataTransitionData{7, 0}, goes to state 21, and sends its 143-byte NetDataAttachSealNetData (zlib). Unanswered, it returns to the room after about 45 s. Answered with 143 bytes (bin/bdsp_connect.py --answer-with 0x15:FILE), it applies them and returns to the room. Send the answer from its own task: from inside the receiver, the ack it waits for is never read.
BallDecoMatching$$ReceiveBallDecoData (0x021ca5e0) writes the first of the 99 capsule slots with no seals, or nothing when all have seals. BallDecoWork$$CopyTradeCapsuleData (0x01f23eb0) clears the slot and places each seal at (x, y, z) / 100 when the save holds one in stock, spending it, and drops it otherwise. The closing message is DLP_net_union_room_090 when every seal was placed, _114 otherwise. A count above 20 or a seal id of 200 or more throws.
Positions are hundredths of the capsule radius. 3D mode draws every seal where it is. 2D mode draws a seal only at a grid cell’s BallDecoWork$$Convert2DPosition (0x01f24da0): radius 1, rows and columns 28 degrees apart on the front (+z) and 25 on the back, each component rounded to 0.01. The grid, front and back, is the 7 by 7 square minus (|x|, |y|) in {(3, 2), (2, 3), (3, 3)} (Capsule2DViewController$$Initialize 0x01e909b0, at the retail 1280 x 720 scale):
row 3 columns -1..1
row 2 columns -2..2
row 1 columns -3..3
row 0 columns -3..3
row -1 columns -3..3
row -2 columns -2..2
row -3 columns -1..1
A 2D design off the grid fills a slot marked decorated and draws nothing.