diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d4129d2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +CONTRIBUTING.md + +Contributions are always welcome :) + +## Updating + +Good sources for the Minecraft bedrock protocol are [gophertunnel](https://github.com/Sandertv/gophertunnel/tree/master/minecraft/protocol/packet), [ClouburstMC's protocol library](https://github.com/CloudburstMC/Protocol) and [PocketMine](https://github.com/pmmp/PocketMine-MP/tree/stable/src/pocketmine/network/mcpe/protocol). + +Steps to update: +* Add the version to src/options.js +* Open [data/latest/proto.yml](https://github.com/PrismarineJS/bedrock-protocol/tree/new/data/latest) and add, remove or modify the updated packets (see the [Packet serialization](#Packet_serialization) notes at the bottom for info on syntax) +* Save and make sure to update the !version field at the top of the file +* Run `npm run build` and `npm test` to test + + +## Packet serialization + +This project uses ProtoDef to serialize and deserialize Minecraft packets. See the documentation [here](https://github.com/ProtoDef-io/node-protodef). +The ProtoDef schema is JSON can be found [here](https://github.com/PrismarineJS/bedrock-protocol/blob/4169453835790de7eeaa8fb6f5a6b4344f71036b/data/1.16.210/protocol.json) for use in other languages. + +In bedrock-protocol, JavaScript code is generated from the JSON through the node-protodef compiler. + +#### YAML syntax + +For easier maintainability, the JSON is generated from a more human readable YAML format. You can read more [here](https://github.com/extremeheat/protodef-yaml). + Some documentation is below. + +Packets should go in proto.yml and extra types should go in types.yml. + +```yml +# This defines a new data structure, a ProtoDef container. +PlayerPosition: + # Variable `x` in this struct has a type of `li32`, a little-endian 32-bit integer + x: li32 + # `z` is a 32-bit LE *unsigned* integer + z: lu32 + # `b` is a 32-bit LE floating point + y: lf32 + +# Fields starting with `packet_` are structs representing Minecraft packets +packet_player_position: + # Fields starting with ! are ignored by the parser. '!id' is used by the parser when generating the packet map + !id: 0x29 # This packet is ID #0x29 + !bound: client # `client` or `server` bound, just for documentation purposes. This has no other effect. + + # Read `on_ground` as a boolean + on_ground: bool + # Read `position` as custom data type `PlayerPosition` defined above. + position: Position + + # Reads a 8-bit unsigned integer, then maps it to a string + movement_reason: u8 => + 0: player_jump + 1: player_autojump + 2: player_sneak + 3: player_sprint + 4: player_fall + + # A `_` as a field name declares an anonymous data structure which will be inlined. Adding a '?' at the end will start a `switch` statement + _: movement_reason ? + # if the condition matches to the string "player_jump" or "player_autojump", there is a data struct that needs to be read + if player_jump or player_autojump: + # read `original_position` as a `Position` + original_position: Position + jump_tick: li64 + # if the condition matches "player_fall", read the containing field + if player_fall: + original_position: Position + default: void + + # Another way to declare a switch, without an anonymous structure. `player_hunger` will be read as a 8-bit int if movement_reason == "player_sprint" + player_hunger: movement_reason ? + if player_sprint: u8 + # The default statement as in a switch statement + default: void + + # Square brackets notate an array. At the left is the type of the array values, at the right is the type of + # the length prefix. If no type at the left is specified, the type is defined below. + + # Reads an array of `Position`, length-prefixed with a ProtoBuf-type unsigned variable length integer (VarInt) + last_positions: Position[]varint + + # Reads an array, length-prefixed with a zigzag-encoded signed VarInt + # The data structure for the array is defined underneath + keys_down: []zigzag32 + up: bool + down: bool + shift: bool +``` + +The above roughly translates to the following JavaScript code to read a packet: +```js +function read_player_position(stream) { + const ret = {} + ret.x = stream.readSignedInt32LE() + ret.z = stream.readUnsignedInt32LE() + ret.y = stream.readFloat32LE() + return ret +} + +function read_player_position(stream) { + const ret = {} + ret.on_ground = Boolean(stream.readU8()) + ret.position = read_player_position(stream) + let __movement_reason = stream.readU8() + let movement_reason = { 0: 'player_jump', 1: 'player_autojump', 2: 'player_sneak', 3: 'player_sprint', 4: 'player_fall' }[__movement_reason] + switch (movement_reason) { + case 'player_jump': + case 'player_autojump': + ret.original_position = read_player_position(stream) + ret.jump_tick = stream.readInt64LE(stream) + break + case 'player_fall': + ret.original_position = read_player_position(stream) + break + default: break + } + ret.player_hunger = undefined + if (movement_reason == 'player_sprint') ret.player_hunger = stream.readU8() + ret.last_positions = [] + for (let i = 0; i < stream.readUnsignedVarInt(); i++) { + ret.last_positions.push(read_player_position(stream)) + } + ret.keys_down = [] + for (let i = 0; i < stream.readZigZagVarInt(); i++) { + const ret1 = {} + ret1.up = Boolean(stream.readU8()) + ret1.down = Boolean(stream.readU8()) + ret1.shift = Boolean(stream.readU8()) + ret.keys_down.push(ret1) + } + return ret +} +``` + +and the results in the following JSON for the packet: +```json +{ + "on_ground": false, + "position": { "x": 0, "y": 2, "z": 0 }, + "movement_reason": "player_jump", + "original_position": { "x": 0, "y": 0, "z": 0 }, + "jump_tick": 494894984, + "last_positions": [{ "x": 0, "y": 1, "z": 0 }], + "keys_down": [] +} +``` + +Custom ProtoDef types can be inlined as JSON: +```yml +string: ["pstring",{"countType":"varint"}] +``` diff --git a/examples/serverTest.js b/examples/serverTest.js index 58b083e..c060954 100644 --- a/examples/serverTest.js +++ b/examples/serverTest.js @@ -27,7 +27,7 @@ async function startServer (version = '1.16.210', ok) { let loop const getPath = (packetPath) => DataProvider(server.options.protocolVersion).getPath(packetPath) - const get = (packetPath) => require(getPath('sample/' + packetPath)) + const get = (packetName) => require(getPath(`sample/packets/${packetName}.json`)) server.listen() console.log('Started server') @@ -71,25 +71,25 @@ async function startServer (version = '1.16.210', ok) { client.queue('inventory_slot', { window_id: 120, slot: 0, item: new Item().toBedrock() }) } - client.write('player_list', get('packets/player_list.json')) - client.write('start_game', get('packets/start_game.json')) + client.write('player_list', get('player_list')) + client.write('start_game', get('start_game')) client.write('item_component', { entries: [] }) - client.write('set_spawn_position', get('packets/set_spawn_position.json')) + client.write('set_spawn_position', get('set_spawn_position')) client.write('set_time', { time: 5433771 }) client.write('set_difficulty', { difficulty: 1 }) client.write('set_commands_enabled', { enabled: true }) - client.write('adventure_settings', get('packets/adventure_settings.json')) - client.write('biome_definition_list', get('packets/biome_definition_list.json')) - client.write('available_entity_identifiers', get('packets/available_entity_identifiers.json')) - client.write('update_attributes', get('packets/update_attributes.json')) - client.write('creative_content', get('packets/creative_content.json')) - client.write('inventory_content', get('packets/inventory_content.json')) + client.write('adventure_settings', get('adventure_settings')) + client.write('biome_definition_list', get('biome_definition_list')) + client.write('available_entity_identifiers', get('available_entity_identifiers')) + client.write('update_attributes', get('update_attributes')) + client.write('creative_content', get('creative_content')) + client.write('inventory_content', get('inventory_content')) client.write('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true }) - client.write('crafting_data', get('packets/crafting_data.json')) - client.write('available_commands', get('packets/available_commands.json')) + client.write('crafting_data', get('crafting_data')) + client.write('available_commands', get('available_commands')) client.write('chunk_radius_update', { chunk_radius: 1 }) - client.write('game_rules_changed', get('packets/game_rules_changed.json')) - client.write('respawn', get('packets/respawn.json')) + client.write('game_rules_changed', get('game_rules_changed')) + client.write('respawn', get('respawn')) for (const chunk of chunks) { client.queue('level_chunk', chunk) diff --git a/tools/dumpPackets.js b/tools/dumpPackets.js new file mode 100644 index 0000000..8ef569a --- /dev/null +++ b/tools/dumpPackets.js @@ -0,0 +1,165 @@ +// dumps (up to 5 of each) packet encountered until 'spawn' event +// uses the same format as prismarine-packet-dumper +const assert = require('assert') +const fs = require('fs') +const vanillaServer = require('../tools/startVanillaServer') +const { Client } = require('../src/client') +const { serialize, waitFor } = require('../src/datatypes/util') +const { CURRENT_VERSION } = require('../src/options') +const path = require('path') + +const output = path.resolve(process.argv[3] ?? 'output') + +let loop + +async function dump (version) { + const random = ((Math.random() * 100) | 0) + const port = 19130 + random + + const handle = await vanillaServer.startServerAndWait(version || CURRENT_VERSION, 1000 * 120, { 'server-port': port }) + + console.log('Started server') + const client = new Client({ + hostname: '127.0.0.1', + port, + username: 'dumpBot', + offline: true + }) + + return waitFor(async res => { + await fs.promises.mkdir(output) + await fs.promises.mkdir(path.join(output, 'from-server')) + + client.once('resource_packs_info', (packet) => { + client.write('resource_pack_client_response', { + response_status: 'completed', + resourcepackids: [] + }) + + client.once('resource_pack_stack', (stack) => { + client.write('resource_pack_client_response', { + response_status: 'completed', + resourcepackids: [] + }) + }) + + client.queue('client_cache_status', { enabled: false }) + client.queue('request_chunk_radius', { chunk_radius: 1 }) + + clearInterval(loop) + loop = setInterval(() => { + client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) }) + }, 200) + }) + + const kindCounter = {} + const MAX_PACKETS_PER_TYPE = 5 + client.on('packet', async packet => { // Packet dumping + const { fullBuffer, data: { name, params } } = packet + if (!packet.data.name) return + if (!kindCounter[packet.name]) { + await fs.promises.mkdir(path.join(output, 'from-server', name), { recursive: true }) + kindCounter[name] = 0 + } + if (kindCounter[name] === MAX_PACKETS_PER_TYPE) return + kindCounter[name]++ + + await fs.promises.writeFile(path.join(output, 'from-server', name, `${kindCounter[name]}.bin`), fullBuffer) + + try { + fs.writeFileSync(path.join(output, 'from-server', name, `${kindCounter[name]}.json`), serialize(params, 2)) + } catch (e) { + console.log(e) + } + }) + + console.log('Awaiting join...') + + client.on('spawn', () => { + console.log('Spawned!') + clearInterval(loop) + client.close() + handle.kill() + res(kindCounter) + }) + }, 1000 * 60, () => { + clearInterval(loop) + handle.kill() + throw Error('timed out') + }) +} + +const makeDropdownStart = (name, arr) => { + arr.push(`
${name}`) + arr.push('

') + arr.push('') +} +const makeDropdownEnd = (arr) => { + arr.push('') + arr.push('

') + arr.push('
') +} + +function makeMarkdown (data) { + const str = [] + const { collected, missing } = data + + makeDropdownStart(`Collected (${collected.length})`, str) + str.push('| Packet |') + str.push('| --- |') + collected.forEach(elem => { + str.push(`| ${elem} |`) + }) + makeDropdownEnd(str) + + makeDropdownStart(`Missing (${missing.length})`, str) + str.push('| Packet |') + str.push('| --- |') + missing.forEach(elem => { + str.push(`| ${elem} |`) + }) + makeDropdownEnd(str) + + return str.join('\n') +} + +function parsePacketCounter (version, kindCounter) { + const protocol = require(`../data/${version}/protocol.json`) + // record packets + return { + collectedPackets: Object.keys(kindCounter), + allPackets: Object.keys(protocol) + .filter(o => o.startsWith('packet_')) + .map(o => o.replace('packet_', '')) + } +} + +async function makeStats (kindCounter, version) { + const { collectedPackets, allPackets } = parsePacketCounter(version, kindCounter) + // write packet data + const data = { + collected: collectedPackets, + missing: allPackets.filter(o => !collectedPackets.includes(o)) + } + const metadataFolder = path.join(output, 'metadata') + + await fs.promises.writeFile(path.join(output, 'README.md'), makeMarkdown(data)) + await fs.promises.mkdir(metadataFolder) + await fs.promises.writeFile(path.join(metadataFolder, 'packets_info.json'), JSON.stringify(data, null, 2)) +} + +async function main () { + const version = process.argv[2] + if (!version) { + console.error('Usage: node dumpPackets.js [outputPath]') + } + const vers = Object.keys(require('../src/options').Versions) + assert(vers.includes(version), 'Version not supported') + if (fs.existsSync(output)) fs.promises.rm(output, { force: true, recursive: true }) + const kindCounter = await dump(version) + await fs.promises.rm(path.join(output, '..', `bds-${version}`), { recursive: true }) + await makeStats(kindCounter, version) + console.log('Successfully dumped packets') +} + +main()