Add packet dumper, new server example, internal client/server test (#4)

* Add packet dumper, configuable vanilla server, client events

* Fix server/client closing

* Add internal server test

* protocol: use WindowID types

* Add internal client/server test

* test timeout fixes

* client example updates

* update server example, use protocol updates
Server example with bedrock-provider
Use 64-bit varints for entity runtime ids

* fix internal test packet path
This commit is contained in:
extremeheat 2021-03-26 05:19:08 -04:00 committed by GitHub
commit a55eaddc98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 718 additions and 243 deletions

View file

@ -8,8 +8,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

48
examples/serverChunks.js Normal file
View file

@ -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 }

View file

@ -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()

View file

@ -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": {

View file

@ -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')

View file

@ -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

View file

@ -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)
}

View file

@ -15,8 +15,8 @@ class Server extends EventEmitter {
/** @type {Object<string, Player>} */
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--
}

View file

@ -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)
}

204
test/internal.js Normal file
View file

@ -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 }

11
test/internal.test.js Normal file
View file

@ -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()
})
})

View file

@ -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', () => {

100
tools/genPacketDumps.js Normal file
View file

@ -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 }

View file

@ -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)

37
types/Item.js Normal file
View file

@ -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
}
}
}
}