diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c565b62..7803afb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,8 @@ on: jobs: build: - runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: diff --git a/data/1.16.201/protocol.json b/data/1.16.201/protocol.json index 2dd5be8..361d139 100644 --- a/data/1.16.201/protocol.json +++ b/data/1.16.201/protocol.json @@ -4531,7 +4531,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", @@ -4552,7 +4552,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "server", @@ -4595,7 +4595,7 @@ [ { "name": "window_id", - "type": "varint" + "type": "WindowID" }, { "name": "slot", @@ -5481,7 +5481,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", @@ -5526,7 +5526,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", diff --git a/data/1.16.210/protocol.json b/data/1.16.210/protocol.json index fe48767..8c34b12 100644 --- a/data/1.16.210/protocol.json +++ b/data/1.16.210/protocol.json @@ -2546,6 +2546,41 @@ } } ], + "WindowIDVarint": [ + "mapper", + { + "type": "varint", + "mappings": { + "0": "inventory", + "1": "first", + "100": "last", + "119": "offhand", + "120": "armor", + "121": "creative", + "122": "hotbar", + "123": "fixed_inventory", + "124": "ui", + "-100": "drop_contents", + "-24": "beacon", + "-23": "trading_output", + "-22": "trading_use_inputs", + "-21": "trading_input_2", + "-20": "trading_input_1", + "-17": "enchant_output", + "-16": "enchant_material", + "-15": "enchant_input", + "-13": "anvil_output", + "-12": "anvil_result", + "-11": "anvil_material", + "-10": "container_input", + "-5": "crafting_use_ingredient", + "-4": "crafting_result", + "-3": "crafting_remove_ingredient", + "-2": "crafting_add_ingredient", + "-1": "none" + } + } + ], "WindowType": [ "mapper", { @@ -3721,7 +3756,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "platform_chat_id", @@ -3818,7 +3853,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "entity_type", @@ -3892,7 +3927,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "item", @@ -3937,7 +3972,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "target", @@ -3950,7 +3985,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "flags", @@ -4094,7 +4129,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "coordinates", @@ -4268,7 +4303,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "event_id", @@ -4346,7 +4381,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "event_id", @@ -4401,7 +4436,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "item", @@ -4426,7 +4461,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "helmet", @@ -4465,7 +4500,7 @@ }, { "name": "target_runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "position", @@ -4526,7 +4561,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "action", @@ -4556,7 +4591,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "metadata", @@ -4573,7 +4608,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "velocity", @@ -4638,7 +4673,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" } ] ], @@ -4663,7 +4698,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" } ] ], @@ -4672,7 +4707,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", @@ -4693,7 +4728,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "server", @@ -4736,7 +4771,7 @@ [ { "name": "window_id", - "type": "varint" + "type": "WindowIDVarint" }, { "name": "slot", @@ -5286,7 +5321,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "status", @@ -5653,7 +5688,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", @@ -5698,7 +5733,7 @@ [ { "name": "window_id", - "type": "u8" + "type": "WindowID" }, { "name": "window_type", @@ -6065,7 +6100,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "unknown0", diff --git a/data/latest/proto.yml b/data/latest/proto.yml index f24b5fb..68a7bbe 100644 --- a/data/latest/proto.yml +++ b/data/latest/proto.yml @@ -393,7 +393,7 @@ packet_add_player: entity_id_self: zigzag64 # The runtime ID of the player. The runtime ID is unique for each world session, # and entities are generally identified in packets using this runtime ID. - runtime_entity_id: varint + runtime_entity_id: varint64 # An identifier only set for particular platforms when chatting (presumably only for # Nintendo Switch). It is otherwise an empty string, and is used to decide which players # are able to chat with each other. @@ -423,7 +423,7 @@ packet_add_entity: !id: 0x0d !bound: client entity_id_self: zigzag64 - runtime_entity_id: varint + runtime_entity_id: varint64 entity_type: string x: lf32 y: lf32 @@ -447,7 +447,7 @@ packet_add_item_entity: !id: 0x0f !bound: client entity_id_self: zigzag64 - runtime_entity_id: varint + runtime_entity_id: varint64 item: Item x: lf32 y: lf32 @@ -461,13 +461,13 @@ packet_add_item_entity: packet_take_item_entity: !id: 0x11 !bound: client - runtime_entity_id: varint + runtime_entity_id: varint64 target: varint packet_move_entity: !id: 0x12 !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 flags: u8 position: vec3f rotation: Rotation @@ -559,7 +559,7 @@ packet_add_painting: !id: 0x16 !bound: client entity_id_self: zigzag64 - runtime_entity_id: varint + runtime_entity_id: varint64 coordinates: BlockCoordinates direction: zigzag32 title: string @@ -675,7 +675,7 @@ packet_block_event: packet_entity_event: !id: 0x1b !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 event_id: u8 => 1: jump 2: hurt_animation @@ -739,7 +739,7 @@ packet_entity_event: packet_mob_effect: !id: 0x1c !bound: client - runtime_entity_id: varint + runtime_entity_id: varint64 event_id: u8 effect_id: zigzag32 amplifier: zigzag32 @@ -765,7 +765,7 @@ packet_inventory_transaction: packet_mob_equipment: !id: 0x1f !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 item: Item slot: u8 selected_slot: u8 @@ -774,7 +774,7 @@ packet_mob_equipment: packet_mob_armor_equipment: !id: 0x20 !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 helmet: Item chestplate: Item leggings: Item @@ -793,7 +793,7 @@ packet_interact: 6: open_inventory # TargetEntityRuntimeID is the runtime ID of the entity that the player interacted with. This is empty # for the InteractActionOpenInventory action type. - target_runtime_entity_id: varint + target_runtime_entity_id: varint64 # Position associated with the ActionType above. For the InteractActionMouseOverEntity, this is the # position relative to the entity moused over over which the player hovered with its mouse/touch. For the # InteractActionLeaveVehicle, this is the position that the player spawns at after leaving the vehicle. @@ -822,7 +822,7 @@ packet_player_action: !bound: server # EntityRuntimeID is the runtime ID of the player. The runtime ID is unique for each world session, and # entities are generally identified in packets using this runtime ID. - runtime_entity_id: varint + runtime_entity_id: varint64 # ActionType is the ID of the action that was executed by the player. It is one of the constants that may # be found above. action: Action @@ -841,14 +841,14 @@ packet_hurt_armor: packet_set_entity_data: !id: 0x27 !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 metadata: MetadataDictionary tick: varint packet_set_entity_motion: !id: 0x28 !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 velocity: vec3f # SetActorLink is sent by the server to initiate an entity link client-side, meaning one entity will start @@ -877,7 +877,7 @@ packet_animate: !id: 0x2c !bound: both action_id: zigzag32 - runtime_entity_id: varint + runtime_entity_id: varint64 packet_respawn: !id: 0x2d @@ -886,7 +886,7 @@ packet_respawn: y: lf32 z: lf32 state: u8 - runtime_entity_id: varint + runtime_entity_id: varint64 # ContainerOpen is sent by the server to open a container client-side. This container must be physically # present in the world, for the packet to have any effect. Unlike Java Edition, Bedrock Edition requires that @@ -896,7 +896,7 @@ packet_container_open: !bound: client # WindowID is the ID representing the window that is being opened. It may be used later to close the # container using a ContainerClose packet. - window_id: u8 + window_id: WindowID # ContainerType is the type ID of the container that is being opened when opening the container at the # position of the packet. It depends on the block/entity, and could, for example, be the window type of # a chest or a hopper, but also a horse inventory. @@ -917,7 +917,7 @@ packet_container_close: !bound: both # WindowID is the ID representing the window of the container that should be closed. It must be equal to # the one sent in the ContainerOpen packet to close the designated window. - window_id: u8 + window_id: WindowID # ServerSide determines whether or not the container was force-closed by the server. If this value is # not set correctly, the client may ignore the packet and respond with a PacketViolationWarning. server: bool @@ -954,7 +954,7 @@ packet_inventory_slot: !bound: both # WindowID is the ID of the window that the packet modifies. It must point to one of the windows that the # client currently has opened. - window_id: varint + window_id: WindowIDVarint # Slot is the index of the slot that the packet modifies. The new item will be set to the slot at this # index. slot: varint @@ -1287,7 +1287,7 @@ packet_boss_event: packet_show_credits: !id: 0x4b !bound: client - runtime_entity_id: varint + runtime_entity_id: varint64 status: zigzag32 # This packet sends a list of commands to the client. Commands can have @@ -1448,7 +1448,7 @@ packet_update_trade: !id: 0x50 !bound: client # WindowID is the ID that identifies the trading window that the client currently has opened. - window_id: u8 + window_id: WindowID # WindowType is an identifier specifying the type of the window opened. In vanilla, it appears this is # always filled out with 15. window_type: WindowType @@ -1486,7 +1486,7 @@ packet_update_equipment: !bound: client # WindowID is the identifier associated with the window that the UpdateEquip packet concerns. It is the # ID sent for the horse inventory that was opened before this packet was sent. - window_id: u8 + window_id: WindowID # WindowType is the type of the window that was opened. Generally, this is the type of a horse inventory, # as the packet is specifically made for that. window_type: WindowType @@ -1647,7 +1647,7 @@ packet_book_edit: packet_npc_request: !id: 0x62 !bound: both - runtime_entity_id: varint + runtime_entity_id: varint64 unknown0: u8 unknown1: string unknown2: u8 diff --git a/data/latest/types.yaml b/data/latest/types.yaml index 1c613d3..6ba41c8 100644 --- a/data/latest/types.yaml +++ b/data/latest/types.yaml @@ -996,6 +996,35 @@ WindowID: i8 => 123: fixed_inventory 124: ui +WindowIDVarint: varint => + -100: drop_contents + -24: beacon + -23: trading_output + -22: trading_use_inputs + -21: trading_input_2 + -20: trading_input_1 + -17: enchant_output + -16: enchant_material + -15: enchant_input + -13: anvil_output + -12: anvil_result + -11: anvil_material + -10: container_input + -5: crafting_use_ingredient + -4: crafting_result + -3: crafting_remove_ingredient + -2: crafting_add_ingredient + -1: none + 0: inventory + 1: first + 100: last + 119: offhand + 120: armor + 121: creative + 122: hotbar + 123: fixed_inventory + 124: ui + WindowType: u8 => 0: container 1: workbench diff --git a/data/provider.js b/data/provider.js index 3f51f02..6ad1af7 100644 --- a/data/provider.js +++ b/data/provider.js @@ -2,7 +2,7 @@ const { Versions } = require('../src/options') const { getFiles } = require('../src/datatypes/util') const { join } = require('path') -const fileMap = {} +let fileMap = {} // Walks all the directories for each of the supported versions in options.js // then builds a file map for each version @@ -23,6 +23,8 @@ function loadVersions () { } module.exports = (protocolVersion) => { + fileMap = {} + loadVersions() return { // Returns the most recent file based on the specified protocolVersion // e.g. if `version` is 1.16 and a file for 1.16 doesn't exist, load from 1.15 file @@ -40,5 +42,3 @@ module.exports = (protocolVersion) => { } } } - -loadVersions() diff --git a/examples/clientTest.js b/examples/clientTest.js index 157f450..bcb644e 100644 --- a/examples/clientTest.js +++ b/examples/clientTest.js @@ -1,10 +1,13 @@ process.env.DEBUG = 'minecraft-protocol raknet' const { Client } = require('bedrock-protocol') +const { ChunkColumn, Version } = require('bedrock-provider') async function test () { const client = new Client({ hostname: '127.0.0.1', port: 19132 + // You can specify version by adding : + // version: '1.16.210' }) client.once('resource_packs_info', (packet) => { @@ -20,23 +23,21 @@ async function test () { }) }) - // client.once('resource_packs_info', (packet) => { - // 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 }) client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) }) - // var read = 0; - // client.on('level_chunk', (packet) => { - // read++ - // fs.writeFileSync(`level_chunk-${read}.json`, JSON.stringify(packet, null, 2)) - // }) + client.on('level_chunk', async packet => { + const cc = new ChunkColumn(Version.v1_4_0, packet.x, packet.z) + await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count) + const blocks = [] + for (let x = 0; x < 16; x++) { + for (let z = 0; z < 16; z++) { + blocks.push(cc.getBlock(x, 0, z)) // Read some blocks in this chunk + } + } + }) } test() diff --git a/examples/serverChunks.js b/examples/serverChunks.js new file mode 100644 index 0000000..0b711c4 --- /dev/null +++ b/examples/serverChunks.js @@ -0,0 +1,48 @@ +// CHUNKS +const { WorldProvider } = require('bedrock-provider') +const { LevelDB } = require('leveldb-zlib') +const { join } = require('path') + +async function loadWorld (version) { + const path = join(__dirname, `../tools/bds-${version}/worlds/Bedrock level/db`) + console.log('Loading world at path', path) // Load world from testing server + const db = new LevelDB(path, { createIfMissing: false }) + await db.open() + const wp = new WorldProvider(db, { dimension: 0 }) + + async function requestChunks (x, z, radius) { + const chunks = [] + const cxStart = (x >> 4) - radius + const cxEnd = (x >> 4) + radius + const czStart = (z >> 4) - radius + const czEnd = (z >> 4) + radius + + for (let cx = cxStart; cx < cxEnd; cx++) { + for (let cz = czStart; cz < czEnd; cz++) { + // console.log('reading chunk at ', cx, cz) + + const cc = await wp.load(cx, cz, true) + if (!cc) { + // console.log('no chunk') + continue + } + const cbuf = await cc.networkEncodeNoCache() + chunks.push({ + x: cx, + z: cz, + sub_chunk_count: cc.sectionsLen, + cache_enabled: false, + blobs: [], + payload: cbuf + }) + // console.log('Ht',cc.sectionsLen,cc.sections) + } + } + + return chunks + } + + return { requestChunks } +} + +module.exports = { loadWorld } diff --git a/examples/serverTest.js b/examples/serverTest.js index 5006736..58b083e 100644 --- a/examples/serverTest.js +++ b/examples/serverTest.js @@ -1,39 +1,57 @@ -const fs = require('fs') -process.env.DEBUG = 'minecraft-protocol raknet' +/** + * bedrock-protocol server example; to run this example you need to clone this repo from git. + * first need to dump some packets from the vanilla server as there is alot of boilerplate + * to send to clients. + * + * In your server implementation, you need to implement each of the following packets to + * get a client to spawn like vanilla. You can look at the dumped packets in `data/1.16.10/sample` + * + * First, dump packets for version 1.16.210 by running `npm run dumpPackets`. + */ +process.env.DEBUG = 'minecraft-protocol' // packet logging +// const fs = require('fs') const { Server } = require('../src/server') -// const CreativeItems = require('../data/creativeitems.json') +const { hasDumps } = require('../tools/genPacketDumps') const DataProvider = require('../data/provider') +const { waitFor } = require('../src/datatypes/util') +const { loadWorld } = require('./serverChunks') -const server = new Server({ +async function startServer (version = '1.16.210', ok) { + if (!hasDumps(version)) { + throw Error('You need to dump some packets first. Run tools/genPacketDumps.js') + } -}) -server.create('0.0.0.0', 19132) + const Item = require('../types/Item')(version) + const port = 19132 + const server = new Server({ hostname: '0.0.0.0', port, version }) + let loop -function getPath (packetPath) { - return DataProvider(server.options.protocolVersion).getPath(packetPath) -} + const getPath = (packetPath) => DataProvider(server.options.protocolVersion).getPath(packetPath) + const get = (packetPath) => require(getPath('sample/' + packetPath)) -function get (packetPath) { - return require(getPath('sample/' + packetPath)) -} + server.listen() + console.log('Started server') -// const ran = false + // Find the center position from the dumped packets + const respawnPacket = get('packets/respawn.json') + const world = await loadWorld(version) + const chunks = await world.requestChunks(respawnPacket.x, respawnPacket.z, 2) -server.on('connect', ({ client }) => { - /** @type {Player} */ - client.on('join', () => { - console.log('Client joined', client.getData()) + // Connect is emitted when a client first joins our server, before authing them + server.on('connect', client => { + // Join is emitted after the client has been authenticated and encryption has started + client.on('join', () => { + console.log('Client joined', client.getData()) - // ResourcePacksInfo is sent by the server to inform the client on what resource packs the server has. It - // sends a list of the resource packs it has and basic information on them like the version and description. - client.write('resource_packs_info', { - must_accept: false, - has_scripts: false, - behaviour_packs: [], - texture_packs: [] - }) + // ResourcePacksInfo is sent by the server to inform the client on what resource packs the server has. It + // sends a list of the resource packs it has and basic information on them like the version and description. + client.write('resource_packs_info', { + must_accept: false, + has_scripts: false, + behaviour_packs: [], + texture_packs: [] + }) - client.once('resource_pack_client_response', async (packet) => { // ResourcePackStack is sent by the server to send the order in which resource packs and behaviour packs // should be applied (and downloaded) by the client. client.write('resource_pack_stack', { @@ -45,109 +63,83 @@ server.on('connect', ({ client }) => { experiments_previously_used: false }) - client.once('resource_pack_client_response', async (packet) => { + client.once('resource_pack_client_response', async rp => { + // Tell the server we will compress everything (>=1 byte) + client.write('network_settings', { compression_threshold: 1 }) + // Send some inventory slots + for (let i = 0; i < 3; i++) { + 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('item_component', { entries: [] }) + client.write('set_spawn_position', get('packets/set_spawn_position.json')) + 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('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('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('network_settings', { - compression_threshold: 1 - }) + for (const chunk of chunks) { + client.queue('level_chunk', chunk) + } - for (let i = 0; i < 3; i++) { - client.queue('inventory_slot', { inventory_id: 120, slot: i, uniqueid: 0, item: { network_id: 0 } }) - } + // Uncomment below and comment above to send dumped chunks. We use bedrock-provider in this example which is still a WIP, some blocks may be broken. + // for (const file of fs.readdirSync(`../data/${server.options.version}/sample/chunks`)) { + // const buffer = fs.readFileSync(`../data/${server.options.version}/sample/chunks/` + file) + // // console.log('Sending chunk', buffer) + // client.sendBuffer(buffer) + // } - client.queue('inventory_transaction', get('packets/inventory_transaction.json')) - client.queue('player_list', get('packets/player_list.json')) - client.queue('start_game', get('packets/start_game.json')) - client.queue('item_component', { entries: [] }) - client.queue('set_spawn_position', get('packets/set_spawn_position.json')) - client.queue('set_time', { time: 5433771 }) - client.queue('set_difficulty', { difficulty: 1 }) - client.queue('set_commands_enabled', { enabled: true }) - client.queue('adventure_settings', get('packets/adventure_settings.json')) + // Constantly send this packet to the client to tell it the center position for chunks. The client should then request these + // missing chunks from the us if it's missing any within the radius. `radius` is in blocks. + loop = setInterval(() => { + client.write('network_chunk_publisher_update', { coordinates: { x: respawnPacket.x, y: 130, z: respawnPacket.z }, radius: 80 }) + }, 4500) - client.queue('biome_definition_list', get('packets/biome_definition_list.json')) - client.queue('available_entity_identifiers', get('packets/available_entity_identifiers.json')) + // Wait some time to allow for the client to recieve and load all the chunks + setTimeout(() => { + // Allow the client to spawn + client.write('play_status', { status: 'player_spawn' }) + }, 6000) - client.queue('update_attributes', get('packets/update_attributes.json')) - client.queue('creative_content', get('packets/creative_content.json')) - client.queue('inventory_content', get('packets/inventory_content.json')) - client.queue('player_hotbar', { selected_slot: 3, window_id: 0, select_slot: true }) - - client.queue('crafting_data', get('packets/crafting_data.json')) - client.queue('available_commands', get('packets/available_commands.json')) - client.queue('chunk_radius_update', { chunk_radius: 5 }) - - client.queue('set_entity_data', get('packets/set_entity_data.json')) - - client.queue('game_rules_changed', get('packets/game_rules_changed.json')) - client.queue('respawn', { x: 646.9405517578125, y: 65.62001037597656, z: 77.86255645751953, state: 0, runtime_entity_id: 0 }) - - for (const file of fs.readdirSync(`../data/${server.options.version}/sample/chunks`)) { - const buffer = Buffer.from(fs.readFileSync(`../data/${server.options.version}/sample/chunks/` + file, 'utf8'), 'hex') - // console.log('Sending chunk', chunk) - client.sendBuffer(buffer) - } - - // for (const chunk of chunks) { - // client.queue('level_chunk', chunk) - // } - - setInterval(() => { - client.write('network_chunk_publisher_update', { coordinates: { x: 646, y: 130, z: 77 }, radius: 64 }) - }, 9500) - - setTimeout(() => { - client.write('play_status', { status: 'player_spawn' }) - }, 8000) - - // Respond to tick synchronization packets - client.on('tick_sync', (packet) => { - client.queue('tick_sync', { - request_time: packet.request_time, - response_time: BigInt(Date.now()) + // Respond to tick synchronization packets + client.on('tick_sync', (packet) => { + client.queue('tick_sync', { + request_time: packet.request_time, + response_time: BigInt(Date.now()) + }) }) }) }) }) + + ok() + + return { + kill: () => { + clearInterval(loop) + server.close() + } + } +} + +let server +waitFor((res) => { + server = startServer(process.argv[2], res) +}, 1000 * 60 /* Wait 60 seconds for the server to start */, function onTimeout () { + console.error('Server did not start in time') + server?.close() + process.exit(1) }) - -// CHUNKS -// const { ChunkColumn, Version } = require('bedrock-provider') -// const mcData = require('minecraft-data')('1.16') -// const chunks = [] -// async function buildChunks () { -// // "x": 40, -// // "z": 4, - -// const stone = mcData.blocksByName.stone - -// for (let cx = 35; cx < 45; cx++) { -// for (let cz = 0; cz < 8; cz++) { -// const column = new ChunkColumn(Version.v1_2_0_bis, x, z) -// for (let x = 0; x < 16; x++) { -// for (let y = 0; y < 60; y++) { -// for (let z = 0; z < 16; z++) { -// column.setBlock(x, y, z, stone) -// } -// } -// } - -// const ser = await column.networkEncodeNoCache() - -// chunks.push({ -// x: cx, -// z: cz, -// sub_chunk_count: column.sectionsLen, -// cache_enabled: false, -// blobs: [], -// payload: ser -// }) -// } -// } - -// // console.log('Chunks',chunks) -// } - -// // buildChunks() diff --git a/package.json b/package.json index aec7b49..9be65ba 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "scripts": { "build": "cd tools && node compileProtocol.js", "prepare": "npm run build", - "test": "mocha", + "test": "mocha --bail", "pretest": "npm run lint", "lint": "standard", "vanillaServer": "node tools/startVanillaServer.js", + "dumpPackets": "node tools/genPacketDumps.js", "fix": "standard --fix" }, "keywords": [ @@ -33,7 +34,7 @@ "node-fetch": "^2.6.1", "prismarine-nbt": "^1.5.0", "protodef": "^1.11.0", - "raknet-native": "^0.1.0", + "raknet-native": "^0.2.0", "uuid-1345": "^1.0.2" }, "devDependencies": { diff --git a/src/auth/loginVerify.js b/src/auth/loginVerify.js index 6d21e41..e1c2743 100644 --- a/src/auth/loginVerify.js +++ b/src/auth/loginVerify.js @@ -17,10 +17,10 @@ module.exports = (client, server, options) => { let pubKey = mcPubKeyToPem(getX5U(chain[0])) // the first one is client signed, allow it let finalKey = null - console.log(pubKey) + // console.log(pubKey) for (const token of chain) { const decoded = JWT.verify(token, pubKey, { algorithms: 'ES384' }) - console.log('Decoded', decoded) + // console.log('Decoded', decoded) // Check if signed by Mojang key const x5u = getX5U(token) @@ -69,7 +69,6 @@ function getX5U (token) { } function mcPubKeyToPem (mcPubKeyBuffer) { - console.log(mcPubKeyBuffer) if (mcPubKeyBuffer[0] === '-') return mcPubKeyBuffer let pem = '-----BEGIN PUBLIC KEY-----\n' let base64PubKey = mcPubKeyBuffer.toString('base64') diff --git a/src/client.js b/src/client.js index 47c5a60..2fb3ce1 100644 --- a/src/client.js +++ b/src/client.js @@ -41,6 +41,7 @@ class Client extends Connection { } this.startGameData = {} + this.clientRuntimeId = null this.startQueue() this.inLog = (...args) => debug('C ->', ...args) @@ -60,6 +61,10 @@ class Client extends Connection { } } + get entityId () { + return this.startGameData.runtime_entity_id + } + onEncapsulated = (encapsulated, inetAddr) => { const buffer = Buffer.from(encapsulated.buffer) this.handle(buffer) @@ -108,30 +113,29 @@ class Client extends Connection { } onDisconnectRequest (packet) { - // We're talking over UDP, so there is no connection to close, instead - // we stop communicating with the server - console.warn(`Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`) - process.exit(1) // TODO: handle + console.warn(`C Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`) + this.emit('kick', packet) } onPlayStatus (statusPacket) { if (this.status === ClientStatus.Initializing && this.options.autoInitPlayer === true) { if (statusPacket.status === 'player_spawn') { this.status = ClientStatus.Initialized - this.write('set_local_player_as_initialized', { runtime_entity_id: this.startGameData.runtime_entity_id }) + this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId }) this.emit('spawn') } } } close () { + this.emit('close') clearInterval(this.loop) clearTimeout(this.connectTimeout) this.q = [] this.q2 = [] this.connection?.close() this.removeAllListeners() - console.log('Closed!') + console.log('Client closed!') } tryRencode (name, params, actual) { @@ -155,22 +159,13 @@ class Client extends Connection { const des = this.deserializer.parsePacketBuffer(packet) const pakData = { name: des.data.name, params: des.data.params } this.inLog('-> C', pakData.name/*, serialize(pakData.params).slice(0, 100) */) + this.emit('packet', des) if (debugging) { // Packet verifying (decode + re-encode + match test) if (pakData.name) { this.tryRencode(pakData.name, pakData.params, packet) } - - // console.info('->', JSON.stringify(pakData, (k,v) => typeof v == 'bigint' ? v.toString() : v)) - // Packet dumping - try { - const root = __dirname + `../data/${this.options.version}/sample/` - if (!fs.existsSync(root + `packets/${pakData.name}.json`)) { - fs.writeFileSync(root + `packets/${pakData.name}.json`, serialize(pakData.params, 2)) - fs.writeFileSync(root + `packets/${pakData.name}.txt`, packet.toString('hex')) - } - } catch { } } // Abstract some boilerplate before sending to listeners @@ -188,7 +183,10 @@ class Client extends Connection { this.onPlayStatus(pakData.params) break default: - // console.log('Sending to listeners') + if (this.status !== ClientStatus.Initializing && this.status !== ClientStatus.Initialized) { + this.inLog(`Can't accept ${des.data.name}, client not yet authenticated : ${this.status}`) + return + } } // Emit packet diff --git a/src/connection.js b/src/connection.js index 220013c..a9061c0 100644 --- a/src/connection.js +++ b/src/connection.js @@ -16,6 +16,8 @@ const ClientStatus = { class Connection extends EventEmitter { status = ClientStatus.Disconnected + q = [] + q2 = [] versionLessThan (version) { if (typeof version === 'string') { @@ -121,7 +123,7 @@ class Connection extends EventEmitter { // TODO: Rename this to sendEncapsulated sendMCPE (buffer, immediate) { - if (this.connection.connected === false) return + if (this.connection.connected === false || this.status === ClientStatus.Disconnected) return this.connection.sendReliable(buffer, immediate) } diff --git a/src/server.js b/src/server.js index 2c5187c..bf440d3 100644 --- a/src/server.js +++ b/src/server.js @@ -15,8 +15,8 @@ class Server extends EventEmitter { /** @type {Object} */ this.clients = {} this.clientCount = 0 - this.inLog = (...args) => debug('C -> S', ...args) - this.outLog = (...args) => debug('S -> C', ...args) + this.inLog = (...args) => debug('S ->', ...args) + this.outLog = (...args) => debug('S <-', ...args) } validateOptions () { @@ -40,6 +40,8 @@ class Server extends EventEmitter { onCloseConnection = (inetAddr, reason) => { console.debug('close connection', inetAddr, reason) + delete this.clients[inetAddr]?.connection // Prevent close loop + this.clients[inetAddr]?.close() delete this.clients[inetAddr] this.clientCount-- } diff --git a/src/serverPlayer.js b/src/serverPlayer.js index 49f7917..b11d8f4 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -1,6 +1,7 @@ const { ClientStatus, Connection } = require('./connection') const fs = require('fs') const Options = require('./options') +const debug = require('debug')('minecraft-protocol') const { Encrypt } = require('./auth/encryption') const Login = require('./auth/login') @@ -21,8 +22,8 @@ class Player extends Connection { this.startQueue() this.status = ClientStatus.Authenticating - this.inLog = (...args) => console.info('S -> C', ...args) - this.outLog = (...args) => console.info('C -> S', ...args) + this.inLog = (...args) => debug('S ->', ...args) + this.outLog = (...args) => debug('S <-', ...args) } getData () { @@ -82,7 +83,7 @@ class Player extends Connection { /** * Disconnects a client */ - disconnect (reason, hide = false) { + disconnect (reason = 'Server closed', hide = false) { if ([ClientStatus.Authenticating, ClientStatus.Initializing].includes(this.status)) { this.sendDisconnectStatus('failed_server_full') } else { @@ -110,6 +111,7 @@ class Player extends Connection { clearInterval(this.loop) this.connection?.close() this.removeAllListeners() + this.status = ClientStatus.Disconnected } readPacket (packet) { @@ -124,10 +126,9 @@ class Player extends Connection { throw e } - console.log('-> S', des) switch (des.data.name) { case 'login': - console.log(des) + // console.log(des) this.onLogin(des) return case 'client_to_server_handshake': @@ -136,11 +137,12 @@ class Player extends Connection { break case 'set_local_player_as_initialized': this.status = ClientStatus.Initialized + this.inLog('Server client spawned') // Emit the 'spawn' event this.emit('spawn') break default: - console.log('ignoring, unhandled') + // console.log('ignoring, unhandled') } this.emit(des.data.name, des.data.params) } diff --git a/test/internal.js b/test/internal.js new file mode 100644 index 0000000..40aeb5e --- /dev/null +++ b/test/internal.js @@ -0,0 +1,204 @@ +// process.env.DEBUG = 'minecraft-protocol raknet' +const { Server, Client } = require('../') +const { dumpPackets, hasDumps } = require('../tools/genPacketDumps') +const DataProvider = require('../data/provider') + +// First we need to dump some packets that a vanilla server would send a vanilla +// client. Then we can replay those back in our custom server. +function prepare (version) { + if (!hasDumps(version)) { + return dumpPackets(version) + } +} + +async function startTest (version = '1.16.210', ok) { + await prepare(version) + const Item = require('../types/Item')(version) + const port = 19130 + const server = new Server({ hostname: '0.0.0.0', port, version }) + + function getPath (packetPath) { + return DataProvider(server.options.protocolVersion).getPath(packetPath) + } + + function get (packetPath) { + return require(getPath('sample/' + packetPath)) + } + + server.listen() + console.log('Started server') + + const respawnPacket = get('packets/respawn.json') + const chunks = await requestChunks(respawnPacket.x, respawnPacket.z, 1) + + let loop + + // server logic + server.on('connect', client => { + client.on('join', () => { + console.log('Client joined', client.getData()) + + client.write('resource_packs_info', { + must_accept: false, + has_scripts: false, + behaviour_packs: [], + texture_packs: [] + }) + + client.once('resource_pack_client_response', async rp => { + // Tell the server we will compress everything (>=1 byte) + client.write('network_settings', { compression_threshold: 1 }) + // Send some inventory slots + for (let i = 0; i < 3; i++) { + client.queue('inventory_slot', { window_id: 'armor', slot: 0, item: new Item().toBedrock() }) + } + + // client.queue('inventory_transaction', get('packets/inventory_transaction.json')) + client.queue('player_list', get('packets/player_list.json')) + client.queue('start_game', get('packets/start_game.json')) + client.queue('item_component', { entries: [] }) + client.queue('set_spawn_position', get('packets/set_spawn_position.json')) + client.queue('set_time', { time: 5433771 }) + client.queue('set_difficulty', { difficulty: 1 }) + client.queue('set_commands_enabled', { enabled: true }) + client.queue('adventure_settings', get('packets/adventure_settings.json')) + + client.queue('biome_definition_list', get('packets/biome_definition_list.json')) + client.queue('available_entity_identifiers', get('packets/available_entity_identifiers.json')) + + client.queue('update_attributes', get('packets/update_attributes.json')) + client.queue('creative_content', get('packets/creative_content.json')) + client.queue('inventory_content', get('packets/inventory_content.json')) + + client.queue('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true }) + + client.queue('crafting_data', get('packets/crafting_data.json')) + client.queue('available_commands', get('packets/available_commands.json')) + client.queue('chunk_radius_update', { chunk_radius: 5 }) + + // client.queue('set_entity_data', get('packets/set_entity_data.json')) + + client.queue('game_rules_changed', get('packets/game_rules_changed.json')) + client.queue('respawn', get('packets/respawn.json')) + + for (const chunk of chunks) { + client.queue('level_chunk', chunk) + } + + loop = setInterval(() => { + client.write('network_chunk_publisher_update', { coordinates: { x: 646, y: 130, z: 77 }, radius: 64 }) + }, 9500) + + setTimeout(() => { + client.write('play_status', { status: 'player_spawn' }) + }, 6000) + + // Respond to tick synchronization packets + client.on('tick_sync', (packet) => { + client.queue('tick_sync', { + request_time: packet.request_time, + response_time: BigInt(Date.now()) + }) + }) + }) + }) + }) + + // client logic + const client = new Client({ + hostname: '127.0.0.1', + port, + username: 'Notch', + version, + offline: true + }) + + console.log('Started client') + + 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 }) + client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) + }) + + client.once('spawn', () => { + console.info('Client spawend!') + setTimeout(() => { + client.close() + + server.close() + ok?.() + }, 500) + clearInterval(loop) + }) +} + +const { ChunkColumn, Version } = require('bedrock-provider') +const { waitFor } = require('../src/datatypes/util') +const mcData = require('minecraft-data')('1.16') + +async function requestChunks (x, z, radius) { + const cxStart = (x >> 4) - radius + const cxEnd = (x >> 4) + radius + const czStart = (z >> 4) - radius + const czEnd = (z >> 4) + radius + + const stone = mcData.blocksByName.stone + const chunks = [] + + for (let cx = cxStart; cx < cxEnd; cx++) { + for (let cz = czStart; cz < czEnd; cz++) { + console.log('reading chunk at ', cx, cz) + const cc = new ChunkColumn(Version.v1_2_0_bis, x, z) + + for (let x = 0; x < 16; x++) { + for (let y = 0; y < 60; y++) { + for (let z = 0; z < 16; z++) { + cc.setBlock(x, y, z, stone) + } + } + } + + if (!cc) { + console.log('no chunk') + continue + } + const cbuf = await cc.networkEncodeNoCache() + chunks.push({ + x: cx, + z: cz, + sub_chunk_count: cc.sectionsLen, + cache_enabled: false, + blobs: [], + payload: cbuf + }) + // console.log('Ht',cc.sectionsLen,cc.sections) + } + } + + return chunks +} + +async function timedTest (version, timeout = 1000 * 120) { + await waitFor((res) => { + startTest(version, res) + }, timeout, () => { + throw Error('timed out') + }) + console.info('✔ ok') +} + +if (!module.parent) timedTest() +module.exports = { startTest, timedTest, requestChunks } diff --git a/test/internal.test.js b/test/internal.test.js new file mode 100644 index 0000000..e3feeac --- /dev/null +++ b/test/internal.test.js @@ -0,0 +1,11 @@ +/* eslint-env jest */ + +const { timedTest } = require('./internal') + +describe('internal client/server test', function () { + this.timeout(120 * 1000) + + it('connects', async () => { + await timedTest() + }) +}) diff --git a/test/vanilla.js b/test/vanilla.js index 1088396..4f37d6b 100644 --- a/test/vanilla.js +++ b/test/vanilla.js @@ -2,6 +2,7 @@ const vanillaServer = require('../tools/startVanillaServer') const { Client } = require('../src/client') const { waitFor } = require('../src/datatypes/util') +const { ChunkColumn, Version } = require('bedrock-provider') async function test (version) { // Start the server, wait for it to accept clients, throws on timeout @@ -42,6 +43,11 @@ async function test (version) { client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) }) }, 200) + client.on('level_chunk', async packet => { // Chunk read test + const cc = new ChunkColumn(Version.v1_4_0, packet.x, packet.z) + await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count) + }) + console.log('Awaiting join') client.on('spawn', () => { diff --git a/tools/genPacketDumps.js b/tools/genPacketDumps.js new file mode 100644 index 0000000..5add677 --- /dev/null +++ b/tools/genPacketDumps.js @@ -0,0 +1,100 @@ +// Collect sample packets needed for `serverTest.js` +// process.env.DEBUG = 'minecraft-protocol' +const fs = require('fs') +const vanillaServer = require('../tools/startVanillaServer') +const { Client } = require('../src/client') +const { serialize, waitFor, getFiles } = require('../src/datatypes/util') +const { CURRENT_VERSION } = require('../src/options') +const { join } = require('path') + +function hasDumps (version) { + const root = join(__dirname, `../data/${version}/sample/packets/`) + if (!fs.existsSync(root) || getFiles(root).length < 10) { + return false + } + return true +} + +let loop + +async function dump (version, force) { + 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: 'Boat' + random, + offline: true + }) + + return waitFor(async res => { + const root = join(__dirname, `../data/${client.options.version}/sample/`) + if (!fs.existsSync(root + 'packets') || !fs.existsSync(root + 'chunks')) { + fs.mkdirSync(root + 'packets', { recursive: true }) + fs.mkdirSync(root + 'chunks', { recursive: true }) + } + + 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 }) + // client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) + + clearInterval(loop) + loop = setInterval(() => { + client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) }) + }, 200) + }) + + let i = 0 + + client.on('packet', async packet => { // Packet dumping + const { name, params } = packet.data + if (name === 'level_chunk') { + fs.writeFileSync(root + `chunks/${name}-${i++}.bin`, packet.buffer) + return + } + try { + if (!fs.existsSync(root + `packets/${name}.json`) || force) { + fs.writeFileSync(root + `packets/${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() + }) + }, 1000 * 60, () => { + clearInterval(loop) + handle.kill() + throw Error('timed out') + }) +} + +if (!module.parent) { + dump(null, true).then(() => { + console.log('Successfully dumped packets') + }) +} +module.exports = { dumpPackets: dump, hasDumps } diff --git a/tools/startVanillaServer.js b/tools/startVanillaServer.js index 83856f4..07587ae 100644 --- a/tools/startVanillaServer.js +++ b/tools/startVanillaServer.js @@ -17,18 +17,18 @@ function fetchLatestStable () { } // Download + extract vanilla server and enter the directory -async function download (os, version) { +async function download (os, version, path = 'bds-') { process.chdir(__dirname) const verStr = version.split('.').slice(0, 3).join('.') - const dir = 'bds-' + version + const dir = path + version if (fs.existsSync(dir) && getFiles(dir).length) { - process.chdir('bds-' + version) // Enter server folder + process.chdir(path + version) // Enter server folder return verStr } try { fs.mkdirSync(dir) } catch { } - process.chdir('bds-' + version) // Enter server folder + process.chdir(path + version) // Enter server folder const url = (os, version) => `https://minecraft.azureedge.net/bin-${os}/bedrock-server-${version}.zip` let found = false @@ -53,10 +53,18 @@ async function download (os, version) { return verStr } +const defaultOptions = { + 'level-generator': '2', + 'server-port': '19130', + 'online-mode': 'false' +} + // Setup the server -function configure () { +function configure (options = {}) { + const opts = { ...defaultOptions, ...options } let config = fs.readFileSync('./server.properties', 'utf-8') - config += '\nlevel-generator=2\nserver-port=19130\nplayer-idle-timeout=1\nallow-cheats=true\ndefault-player-permission-level=operator\nonline-mode=false' + config += '\nplayer-idle-timeout=1\nallow-cheats=true\ndefault-player-permission-level=operator' + for (const o in opts) config += `\n${o}=${opts[o]}` fs.writeFileSync('./server.properties', config) } @@ -66,13 +74,13 @@ function run (inheritStdout = true) { } // Run the server -async function startServer (version, onStart) { +async function startServer (version, onStart, options = {}) { const os = process.platform === 'win32' ? 'win' : process.platform if (os !== 'win' && os !== 'linux') { throw Error('unsupported os ' + os) } - await download(os, version) - configure() + await download(os, version, options.path) + configure(options) const handle = run(!onStart) if (onStart) { handle.stdout.on('data', data => data.includes('Server started.') ? onStart() : null) @@ -83,10 +91,10 @@ async function startServer (version, onStart) { } // Start the server and wait for it to be ready, with a timeout -async function startServerAndWait (version, withTimeout) { +async function startServerAndWait (version, withTimeout, options) { let handle await waitFor(async res => { - handle = await startServer(version, res) + handle = await startServer(version, res, options) }, withTimeout, () => { handle?.kill() throw new Error('Server did not start on time ' + withTimeout) diff --git a/types/Item.js b/types/Item.js new file mode 100644 index 0000000..eb4d804 --- /dev/null +++ b/types/Item.js @@ -0,0 +1,37 @@ +module.exports = (version) => + class Item { + nbt + constructor (obj) { + this.networkId = 0 + this.runtimeId = 0 + this.count = 0 + this.metadata = 0 + Object.assign(this, obj) + this.version = version + } + + static fromBedrock (obj) { + return new Item({ + runtimeId: obj.runtime_id, + networkId: obj.item?.network_id, + count: obj.item?.auxiliary_value & 0xff, + metadata: obj.item?.auxiliary_value >> 8, + nbt: obj.item?.nbt?.nbt + }) + } + + toBedrock () { + return { + runtime_id: this.runtimeId, + item: { + network_id: this.networkId, + auxiliary_value: (this.metadata << 8) | (this.count & 0xff), + has_nbt: !!this.nbt, + nbt: { version: 1, nbt: this.nbt }, + can_place_on: [], + can_destroy: [], + blocking_tick: 0 + } + } + } + }