diff --git a/.eslintignore b/.eslintignore index e69de29..61743e4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -0,0 +1 @@ +examples/viewer \ No newline at end of file diff --git a/data/1.16.210/protocol.json b/data/1.16.210/protocol.json index 8c34b12..47043ad 100644 --- a/data/1.16.210/protocol.json +++ b/data/1.16.210/protocol.json @@ -840,11 +840,11 @@ } ] ], - "creative": [ + "craft": [ "container", [ { - "name": "inventory_id", + "name": "action", "type": "varint" } ] @@ -858,15 +858,6 @@ } ] ], - "craft": [ - "container", - [ - { - "name": "action", - "type": "varint" - } - ] - ], "craft_slot": [ "container", [ @@ -1083,8 +1074,8 @@ "container", [ { - "name": "runtime_id", - "type": "zigzag32" + "name": "stack_id", + "type": "varint" }, { "name": "item", @@ -1265,7 +1256,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ], @@ -1310,7 +1301,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ], @@ -1355,7 +1346,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ], @@ -1414,7 +1405,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ], @@ -1473,7 +1464,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ], @@ -1524,7 +1515,7 @@ }, { "name": "network_id", - "type": "zigzag32" + "type": "varint" } ] ] @@ -2058,7 +2049,7 @@ "22": "stop_swimming", "23": "start_spin_attack", "24": "stop_spin_attack", - "25": "ineract_block", + "25": "interact_block", "26": "predict_break", "27": "continue_break" } @@ -2068,11 +2059,11 @@ "container", [ { - "name": "container_id", - "type": "u8" + "name": "slot_type", + "type": "ContainerSlotType" }, { - "name": "slot_id", + "name": "slot", "type": "u8" }, { @@ -2086,7 +2077,7 @@ [ { "name": "request_id", - "type": "zigzag32" + "type": "varint" }, { "name": "actions", @@ -2282,7 +2273,7 @@ "container", [ { - "name": "creative_item_network_id", + "name": "item_id", "type": "varint32" } ] @@ -2367,58 +2358,75 @@ "type": "varint32" }, { - "name": "containers", + "anon": true, "type": [ - "array", + "switch", { - "countType": "varint", - "type": [ - "container", - [ - { - "name": "slot_type", - "type": "ContainerSlotType" - }, - { - "name": "slots", - "type": [ - "array", - { - "countType": "varint", - "type": [ - "container", - [ - { - "name": "slot", - "type": "u8" - }, - { - "name": "hotbar_slot", - "type": "u8" - }, - { - "name": "count", - "type": "u8" - }, - { - "name": "item_stack_id", - "type": "varint32" - }, - { - "name": "custom_name", - "type": "string" - }, - { - "name": "durability_correction", - "type": "zigzag32" - } + "compareTo": "status", + "fields": { + "ok": [ + "container", + [ + { + "name": "containers", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "slot_type", + "type": "ContainerSlotType" + }, + { + "name": "slots", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "slot", + "type": "u8" + }, + { + "name": "hotbar_slot", + "type": "u8" + }, + { + "name": "count", + "type": "u8" + }, + { + "name": "item_stack_id", + "type": "varint32" + }, + { + "name": "custom_name", + "type": "string" + }, + { + "name": "durability_correction", + "type": "zigzag32" + } + ] + ] + } + ] + } + ] ] - ] - } - ] - } + } + ] + } + ] ] - ] + }, + "default": "void" } ] } @@ -2584,7 +2592,7 @@ "WindowType": [ "mapper", { - "type": "u8", + "type": "i8", "mappings": { "0": "container", "1": "workbench", @@ -2619,7 +2627,9 @@ "30": "cartography", "31": "hud", "32": "jigsaw_editor", - "33": "smithing_table" + "33": "smithing_table", + "-9": "none", + "-1": "inventory" } } ], @@ -4451,7 +4461,7 @@ "type": "u8" }, { - "name": "windows_id", + "name": "window_id", "type": "WindowID" } ] @@ -4499,7 +4509,7 @@ ] }, { - "name": "target_runtime_entity_id", + "name": "target_entity_id", "type": "varint64" }, { @@ -4757,8 +4767,8 @@ "container", [ { - "name": "inventory_id", - "type": "varint" + "name": "window_id", + "type": "WindowIDVarint" }, { "name": "input", @@ -4870,7 +4880,20 @@ ], "packet_gui_data_pick_item": [ "container", - [] + [ + { + "name": "item_name", + "type": "string" + }, + { + "name": "item_effects", + "type": "string" + }, + { + "name": "hotbar_slot", + "type": "li32" + } + ] ], "packet_adventure_settings": [ "container", @@ -6283,14 +6306,14 @@ }, { "name": "entity_unique_id", - "type": "varint" + "type": "zigzag64" }, { "name": "transition_type", "type": [ "mapper", { - "type": "varint", + "type": "varint64", "mappings": { "0": "entity", "1": "create", @@ -7162,7 +7185,7 @@ "type": [ "switch", { - "compareTo": "types.feet", + "compareTo": "type.feet", "fields": { "true": "zigzag32" }, diff --git a/data/latest/proto.yml b/data/latest/proto.yml index 68a7bbe..e46463d 100644 --- a/data/latest/proto.yml +++ b/data/latest/proto.yml @@ -769,7 +769,7 @@ packet_mob_equipment: item: Item slot: u8 selected_slot: u8 - windows_id: WindowID + window_id: WindowID packet_mob_armor_equipment: !id: 0x20 @@ -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: varint64 + target_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. @@ -845,10 +845,16 @@ packet_set_entity_data: metadata: MetadataDictionary tick: varint +# SetActorMotion is sent by the server to change the client-side velocity of an entity. It is usually used +# in combination with server-side movement calculation. packet_set_entity_motion: !id: 0x28 !bound: both + # EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and + # entities are generally identified in packets using this runtime ID. runtime_entity_id: varint64 + # Velocity is the new velocity the entity gets. This velocity will initiate the client-side movement of + # the entity. velocity: vec3f # SetActorLink is sent by the server to initiate an entity link client-side, meaning one entity will start @@ -941,7 +947,7 @@ packet_inventory_content: !bound: both # WindowID is the ID that identifies one of the windows that the client currently has opened, or one of # the consistent windows such as the main inventory. - inventory_id: varint + window_id: WindowIDVarint # Content is the new content of the inventory. The length of this slice must be equal to the full size of # the inventory window updated. input: ItemStacks @@ -1007,10 +1013,19 @@ packet_crafting_event: # Output is a list of items that were obtained as a result of crafting the recipe. result: Item[]varint - +# GUIDataPickItem is sent by the server to make the client 'select' a hot bar slot. It currently appears to +# be broken however, and does not actually set the selected slot to the hot bar slot set in the packet. packet_gui_data_pick_item: !id: 0x36 !bound: client + # ItemName is the name of the item that shows up in the top part of the popup that shows up when + # selecting an item. It is shown as if an item was selected by the player itself. + item_name: string + # ItemEffects is the line under the ItemName, where the effects of the item are usually situated. + item_effects: string + # HotBarSlot is the hot bar slot to be selected/picked. This does not currently work, so it does not + # matter what number this is. + hotbar_slot: li32 # AdventureSettings is sent by the server to update game-play related features, in particular permissions to # access these features for the client. It includes allowing the player to fly, build and mine, and attack @@ -1745,11 +1760,11 @@ packet_update_block_synced: # entity transitions from. # Note that for both possible values for TransitionType, the EntityUniqueID should point to the falling # block entity involved. - entity_unique_id: varint + entity_unique_id: zigzag64 # TransitionType is the type of the transition that happened. It is either BlockToEntityTransition, when # a block placed becomes a falling entity, or EntityToBlockTransition, when a falling entity hits the # ground and becomes a solid block again. - transition_type: varint => + transition_type: varint64 => # For falling sand, when a sand turns to an entity 0: entity # When sand turns back to a new block @@ -2231,7 +2246,7 @@ packet_player_armor_damage: if true: zigzag32 leggings_damage: type.legs ? if true: zigzag32 - boots_damage: types.feet ? + boots_damage: type.feet ? if true: zigzag32 ArmorDamageType: [ "bitflags", diff --git a/data/latest/types.yaml b/data/latest/types.yaml index 6ba41c8..7d85243 100644 --- a/data/latest/types.yaml +++ b/data/latest/types.yaml @@ -448,7 +448,7 @@ TransactionActions: 100: craft_slot 99999: craft _: source_type? - if container or creative: + if container or craft: inventory_id: varint if world_interaction: flags: varint @@ -548,7 +548,7 @@ ItemStack: # StackNetworkID is the network ID of the item stack. If the stack is empty, 0 is always written for this # field. If not, the field should be set to 1 if the server authoritative inventories are disabled in the # StartGame packet, or to a unique stack ID if it is enabled. - runtime_id: zigzag32 + stack_id: varint # Stack is the actual item stack of the item instance. item: Item @@ -577,16 +577,16 @@ PotionContainerChangeRecipes: []varint Recipes: []varint type: zigzag32 => - '0': 'shapeless' #'ENTRY_SHAPELESS', - '1': 'shaped' #'ENTRY_SHAPED', - '2': 'furnace' # 'ENTRY_FURNACE', + 0: shapeless #'ENTRY_SHAPELESS', + 1: shaped #'ENTRY_SHAPED', + 2: furnace # 'ENTRY_FURNACE', # `furnace_with_metadata` is a recipe specifically used for furnace-type crafting stations. It is equal to # `furnace`, except it has an input item with a specific metadata value, instead of any metadata value. - '3': 'furnace_with_metadata' # 'ENTRY_FURNACE_DATA', // has metadata - '4': 'multi' #'ENTRY_MULTI', //TODO - '5': 'shulker_box' #'ENTRY_SHULKER_BOX', //TODO - '6': 'shapeless_chemistry' #'ENTRY_SHAPELESS_CHEMISTRY', //TODO - '7': 'shaped_chemistry' #'ENTRY_SHAPED_CHEMISTRY', //TODO + 3: furnace_with_metadata # 'ENTRY_FURNACE_DATA', // has metadata + 4: multi #'ENTRY_MULTI', //TODO + 5: shulker_box #'ENTRY_SHULKER_BOX', //TODO + 6: shapeless_chemistry #'ENTRY_SHAPELESS_CHEMISTRY', //TODO + 7: shaped_chemistry #'ENTRY_SHAPED_CHEMISTRY', //TODO recipe: type? if shapeless or shulker_box or shapeless_chemistry: recipe_id: string @@ -595,7 +595,7 @@ Recipes: []varint uuid: uuid block: string priority: zigzag32 - network_id: zigzag32 + network_id: varint if shaped or shaped_chemistry: recipe_id: string width: zigzag32 @@ -608,7 +608,7 @@ Recipes: []varint uuid: uuid block: string priority: zigzag32 - network_id: zigzag32 + network_id: varint if furnace: input_id: zigzag32 output: Item @@ -620,7 +620,7 @@ Recipes: []varint block: string if multi: uuid: uuid - network_id: zigzag32 + network_id: varint SkinImage: width: li32 @@ -748,13 +748,20 @@ Action: zigzag32 => 22: stop_swimming 23: start_spin_attack 24: stop_spin_attack - 25: ineract_block + 25: interact_block 26: predict_break 27: continue_break +# Source and Destination point to the source slot from which Count of the item stack were taken and the +# destination slot to which this item was moved. StackRequestSlotInfo: - container_id: u8 - slot_id: u8 + # ContainerID is the ID of the container that the slot was in. + slot_type: ContainerSlotType + # Slot is the index of the slot within the container with the ContainerID above. + slot: u8 + # StackNetworkID is the unique stack ID that the client assumes to be present in this slot. The server + # must check if these IDs match. If they do not match, servers should reject the stack request that the + # action holding this info was in. stack_id: zigzag32 # ItemStackRequest is sent by the client to change item stacks in an inventory. It is essentially a @@ -764,7 +771,7 @@ StackRequestSlotInfo: ItemStackRequest: # RequestID is a unique ID for the request. This ID is used by the server to send a response for this # specific request in the ItemStackResponse packet. - request_id: zigzag32 + request_id: varint actions: []varint type_id: u8 => # TakeStackRequestAction is sent by the client to the server to take x amount of items from one slot in a @@ -872,9 +879,9 @@ ItemStackRequest: # of 1.16. recipe_network_id: varint if craft_creative: - # CreativeItemNetworkID is the network ID of the creative item that is being created. This is one of the - # creative item network IDs sent in the CreativeContent packet. - creative_item_network_id: varint32 + # The stack ID of the creative item that is being created. This is one of the + # creative item stack IDs sent in the CreativeContent packet. + item_id: varint32 if optional: # For the cartography table, if a certain MULTI recipe is being called, this points to the network ID that was assigned. recipe_network_id: varint @@ -901,30 +908,32 @@ ItemStackResponses: []varint # RequestID is the unique ID of the request that this response is in reaction to. If rejected, the client # will undo the actions from the request with this ID. request_id: varint32 - # ContainerInfo holds information on the containers that had their contents changed as a result of the - # request. - containers: []varint - # ContainerID is the container ID of the container that the slots that follow are in. For the main - # inventory, this value seems to be 0x1b. For the cursor, this value seems to be 0x3a. For the crafting - # grid, this value seems to be 0x0d. - # * actually, this is ContainerSlotType - used by the inventory system that specifies the type of slot - slot_type: ContainerSlotType - # SlotInfo holds information on what item stack should be present in specific slots in the container. - slots: []varint - # Slot and HotbarSlot seem to be the same value every time: The slot that was actually changed. I'm not - # sure if these slots ever differ. - slot: u8 - hotbar_slot: u8 - # Count is the total count of the item stack. This count will be shown client-side after the response is - # sent to the client. - count: u8 - # StackNetworkID is the network ID of the new stack at a specific slot. - item_stack_id: varint32 - # CustomName is the custom name of the item stack. It is used in relation to text filtering. - custom_name: string - # DurabilityCorrection is the current durability of the item stack. This durability will be shown - # client-side after the response is sent to the client. - durability_correction: zigzag32 + _: status ? + if ok: + # ContainerInfo holds information on the containers that had their contents changed as a result of the + # request. + containers: []varint + # ContainerID is the container ID of the container that the slots that follow are in. For the main + # inventory, this value seems to be 0x1b. For the cursor, this value seems to be 0x3a. For the crafting + # grid, this value seems to be 0x0d. + # * actually, this is ContainerSlotType - used by the inventory system that specifies the type of slot + slot_type: ContainerSlotType + # SlotInfo holds information on what item stack should be present in specific slots in the container. + slots: []varint + # Slot and HotbarSlot seem to be the same value every time: The slot that was actually changed. I'm not + # sure if these slots ever differ. + slot: u8 + hotbar_slot: u8 + # Count is the total count of the item stack. This count will be shown client-side after the response is + # sent to the client. + count: u8 + # StackNetworkID is the network ID of the new stack at a specific slot. + item_stack_id: varint32 + # CustomName is the custom name of the item stack. It is used in relation to text filtering. + custom_name: string + # DurabilityCorrection is the current durability of the item stack. This durability will be shown + # client-side after the response is sent to the client. + durability_correction: zigzag32 ItemComponentList: []varint @@ -1025,7 +1034,9 @@ WindowIDVarint: varint => 123: fixed_inventory 124: ui -WindowType: u8 => +WindowType: i8 => + -9: none + -1: inventory 0: container 1: workbench 2: furnace @@ -1061,6 +1072,7 @@ WindowType: u8 => 32: jigsaw_editor 33: smithing_table +# Used in inventory transactions. ContainerSlotType: u8 => - anvil_input - anvil_material diff --git a/examples/createRelay.js b/examples/createRelay.js index 31d8553..d3b76ad 100644 --- a/examples/createRelay.js +++ b/examples/createRelay.js @@ -32,7 +32,7 @@ function createRelay () { } }) - relay.create() + relay.listen() } createRelay() diff --git a/examples/viewer/client/BotProvider.js b/examples/viewer/client/BotProvider.js new file mode 100644 index 0000000..4d23377 --- /dev/null +++ b/examples/viewer/client/BotProvider.js @@ -0,0 +1,58 @@ +const { Version } = require('bedrock-provider') +const { WorldView } = require('prismarine-viewer/viewer') +const World = require('prismarine-world')() +const ChunkColumn = require('./Chunk')() +const { MovementManager } = require('./movements') + +class BotProvider extends WorldView { + chunks = {} + lastSentPos + positionUpdated = true + + constructor () { + super() + this.connect() + this.listenToBot() + this.world = new World() + this.movements = new MovementManager(this) + + this.onKeyDown = () => {} + this.onKeyUp = () => {} + + this.removeAllListeners('mouseClick') + } + + raycast () { + // TODO : fix + } + + get entity () { return this.movements.player.entity } + + handleChunk (packet, render = true) { + const hash = (packet.x << 4) + ',' + (packet.z << 4) + if (this.loadChunk[hash]) return + const cc = new ChunkColumn(Version.v1_4_0, packet.x, packet.z) + cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count).then(() => { + this.loadedChunks[hash] = true + this.world.setColumn(packet.x, packet.z, cc) + const chunk = cc.serialize() + // console.log('Chunk', chunk) + if (render) this.emitter.emit('loadChunk', { x: packet.x << 4, z: packet.z << 4, chunk }) + }) + } + + updatePlayerCamera (id, position, yaw, pitch, updateState) { + this.emit('playerMove', id, { position, yaw, pitch }) + + if (updateState) { + this.movements.updatePosition(position, yaw, pitch) + } + } + + stopBot () { + clearInterval(this.tickLoop) + this.movements.stopPhys() + } +} + +module.exports = { BotProvider } diff --git a/examples/viewer/client/BotViewer.js b/examples/viewer/client/BotViewer.js new file mode 100644 index 0000000..57f9d16 --- /dev/null +++ b/examples/viewer/client/BotViewer.js @@ -0,0 +1,150 @@ +/* global THREE */ +const { Viewer, MapControls } = require('prismarine-viewer/viewer') +// const { Vec3 } = require('vec3') +const { ClientProvider } = require('./ClientProvider') +// const { ProxyProvider } = require('./ProxyProvider') +global.THREE = require('three') + +const MCVER = '1.16.1' + +class BotViewer { + start () { + this.bot = new ClientProvider() + // this.bot = new ProxyProvider() + // Create three.js context, add to page + this.renderer = new THREE.WebGLRenderer() + this.renderer.setPixelRatio(window.devicePixelRatio || 1) + this.renderer.setSize(window.innerWidth, window.innerHeight) + document.body.appendChild(this.renderer.domElement) + + // Create viewer + this.viewer = new Viewer(this.renderer) + this.viewer.setVersion(MCVER) + // Attach controls to viewer + this.controls = new MapControls(this.viewer.camera, this.renderer.domElement) + // Enable damping (inertia) on movement + this.controls.enableDamping = true + this.controls.dampingFactor = 0.09 + console.info('Registered handlers') + // Link WorldView and Viewer + this.viewer.listen(this.bot) + + this.bot.on('spawn', ({ position, firstPerson }) => { + // Initialize viewer, load chunks + this.bot.init(position) + // Start listening for keys + this.registerBrowserEvents() + + if (firstPerson && this.bot.movements) { + this.viewer.camera.position.set(position.x, position.y, position.z) + this.firstPerson = true + this.controls.enabled = false + } else { + this.viewer.camera.position.set(position.x, position.y, position.z) + } + }) + + this.bot.on('playerMove', (id, pos) => { + if (this.firstPerson && id < 10) { + this.setFirstPersonCamera(pos) + return + } + + window.viewer.viewer.entities.update({ + name: 'player', + id, + pos: pos.position, + width: 0.6, + height: 1.8, + yaw: pos.yaw, + pitch: pos.pitch + }) + }) + + const oldFov = this.viewer.camera.fov + const sprintFov = this.viewer.camera.fov + 20 + const sneakFov = this.viewer.camera.fov - 10 + + const onSprint = () => { + this.viewer.camera.fov = sprintFov + this.viewer.camera.updateProjectionMatrix() + } + + const onSneak = () => { + this.viewer.camera.fov = sneakFov + this.viewer.camera.updateProjectionMatrix() + } + + const onRelease = () => { + this.viewer.camera.fov = oldFov + this.viewer.camera.updateProjectionMatrix() + } + + this.bot.on('startSprint', onSprint) + this.bot.on('startSneak', onSneak) + this.bot.on('stopSprint', onRelease) + this.bot.on('stopSneak', onRelease) + + this.controls.update() + + // Browser animation loop + const animate = () => { + window.requestAnimationFrame(animate) + if (this.controls && !this.firstPerson) this.controls.update() + this.viewer.update() + this.renderer.render(this.viewer.scene, this.viewer.camera) + } + animate() + + window.addEventListener('resize', () => { + this.viewer.camera.aspect = window.innerWidth / window.innerHeight + this.viewer.camera.updateProjectionMatrix() + this.renderer.setSize(window.innerWidth, window.innerHeight) + }) + } + + onMouseMove = (e) => { + if (this.firstPerson) { + this.bot.entity.pitch -= e.movementY * 0.005 + this.bot.entity.yaw -= e.movementX * 0.004 + } + } + + onPointerLockChange = () => { + const e = this.renderer.domElement + if (document.pointerLockElement === e) { + e.parentElement.addEventListener('mousemove', this.onMouseMove, { passive: true }) + } else { + e.parentElement.removeEventListener('mousemove', this.onMouseMove, false) + } + } + + onMouseDown = () => { + if (this.firstPerson && !document.pointerLockElement) { + this.renderer.domElement.requestPointerLock() + } + } + + registerBrowserEvents () { + const e = this.renderer.domElement + e.parentElement.addEventListener('keydown', this.bot.onKeyDown) + e.parentElement.addEventListener('keyup', this.bot.onKeyUp) + e.parentElement.addEventListener('mousedown', this.onMouseDown) + document.addEventListener('pointerlockchange', this.onPointerLockChange, false) + } + + unregisterBrowserEvents () { + const e = this.renderer.domElement + e.parentElement.removeEventListener('keydown', this.bot.onKeyDown) + e.parentElement.removeEventListener('keyup', this.bot.onKeyUp) + e.parentElement.removeEventListener('mousemove', this.onMouseMove) + e.parentElement.removeEventListener('mousedown', this.onMouseDown) + document.removeEventListener('pointerlockchange', this.onPointerLockChange, false) + } + + setFirstPersonCamera (entity) { + this.viewer.setFirstPersonCamera(entity.position, entity.yaw, entity.pitch * 2) + } +} + +module.exports = { BotViewer } diff --git a/examples/viewer/client/Chunk.js b/examples/viewer/client/Chunk.js new file mode 100644 index 0000000..7733cc3 --- /dev/null +++ b/examples/viewer/client/Chunk.js @@ -0,0 +1,18 @@ +const { ChunkColumn } = require('bedrock-provider') + +const Block = require('prismarine-block')('1.16.1') + +class ChunkColumnWrapped extends ChunkColumn { // pchunk compatiblity wrapper + // Block access + setBlockStateId (pos, stateId) { + super.setBlock(pos.x, pos.y, pos.z, Block.fromStateId(stateId)) + } + + getBlockStateId (pos) { + return super.getBlock(pos.x, pos.y, pos.z)?.stateId + } +} + +module.exports = (version) => { + return ChunkColumnWrapped +} diff --git a/examples/viewer/client/ClientProvider.js b/examples/viewer/client/ClientProvider.js new file mode 100644 index 0000000..e8f3ada --- /dev/null +++ b/examples/viewer/client/ClientProvider.js @@ -0,0 +1,114 @@ +const { Client } = require('bedrock-protocol') +const { BotProvider } = require('./BotProvider') + +const controlMap = { + forward: ['KeyW', 'KeyZ'], + back: 'KeyS', + left: ['KeyA', 'KeyQ'], + right: 'KeyD', + sneak: 'ShiftLeft', + jump: 'Space' +} + +class ClientProvider extends BotProvider { + downKeys = new Set() + + connect () { + const client = new Client({ hostname: '127.0.0.1', version: '1.16.210', username: 'notch', offline: true, port: 19132, connectTimeout: 100000 }) + + 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 }) + + this.heartbeat = setInterval(() => { + client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) + }) + }) + + this.client = client + } + + close () { + this.client?.close() + } + + listenToBot () { + this.client.on('connect', () => { + console.log('Bot has connected!') + }) + this.client.on('start_game', packet => { + this.updatePosition(packet.player_position) + this.movements.init('server', packet.player_position, /* vel */ null, packet.rotation.z || 0, packet.rotation.x || 0, 0) + }) + + this.client.on('spawn', () => { + this.movements.startPhys() + // server allows client to render chunks & spawn in world + this.emit('spawn', { position: this.lastPos, firstPerson: true }) + + this.tickLoop = setInterval(() => { + this.client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n }) + }) + }) + + this.client.on('level_chunk', packet => { + this.handleChunk(packet) + }) + + this.client.on('move_player', packet => { + if (packet.runtime_id === this.client.entityId) { + this.movements.updatePosition(packet.position, packet.yaw, packet.pitch, packet.head_yaw, packet.tick) + } + }) + + this.client.on('set_entity_motion', packet => { + // if (packet.runtime_id === this.client.entityId) this.updatePosition(packet.position) + }) + + this.client.on('tick_sync', (packet) => { + this.lastTick = packet.response_time + }) + } + + onKeyDown = (evt) => { + const code = evt.code + for (const control in controlMap) { + if (controlMap[control].includes(code)) { + this.movements.setControlState(control, true) + break + } + if (evt.ctrlKey) { + this.movements.setControlState('sprint', true) + } + } + this.downKeys.add(code) + } + + onKeyUp = (evt) => { + const code = evt.code + if (code === 'ControlLeft' && this.downKeys.has('ControlLeft')) { + this.movements.setControlState('sprint', false) + } + for (const control in controlMap) { + if (controlMap[control].includes(code)) { + this.movements.setControlState(control, false) + break + } + } + this.downKeys.delete(code) + } +} + +module.exports = { ClientProvider } diff --git a/examples/viewer/client/ProxyProvider.js b/examples/viewer/client/ProxyProvider.js new file mode 100644 index 0000000..de97ee6 --- /dev/null +++ b/examples/viewer/client/ProxyProvider.js @@ -0,0 +1,89 @@ +const { Relay } = require('bedrock-protocol') +const { BotProvider } = require('./BotProvider') +const { diff } = require('./util') + +class ProxyProvider extends BotProvider { + lastPlayerMovePacket + + connect () { + const proxy = new Relay({ + hostname: '0.0.0.0', + port: 19130, + // logging: true, + destination: { + hostname: '127.0.0.1', + port: 19132 + } + }) + proxy.listen() + console.info('Waiting for connect') + + proxy.on('join', (client, server) => { + client.on('clientbound', ({ name, params }) => { + if (name === 'level_chunk') { + this.handleChunk(params, true) + } else if (name === 'start_game') { + this.movements.init('', params.player_position, null, params.rotation.z, params.rotation.x, 0) + } else if (name === 'play_status') { + this.movements.startPhys() + this.emit('spawn', { position: this.movements.lastPos, firstPerson: true }) + console.info('Started physics!') + } else if (name === 'move_player') { + console.log('move_player', params) + this.movements.updatePosition(params.position, params.yaw, params.pitch, params.head_yaw, params.tick) + } + + if (name.includes('entity') || name.includes('network_chunk_publisher_update') || name.includes('tick') || name.includes('level')) return + console.log('CB', name) + }) + + client.on('serverbound', ({ name, params }) => { + // { name, params } + if (name === 'player_auth_input') { + this.movements.pushInputState(params.input_data, params.yaw, params.pitch) + this.movements.pushCameraControl(params, 1) + + // Log Movement deltas + { + this.lastMovePacket = params + if (this.firstPlayerMovePacket) { + const id = diff(this.firstPlayerMovePacket.input_data, params.input_data) + const md = diff(this.firstPlayerMovePacket.move_vector, params.move_vector) + const dd = diff(this.firstPlayerMovePacket.delta, params.delta) + if (id || md) { + if (globalThis.logging) console.log('Move', params.position, id, md, dd) + globalThis.movements ??= [] + globalThis.movements.push(params) + } + } + if (!this.firstPlayerMovePacket) { + this.firstPlayerMovePacket = params + for (const key in params.input_data) { + params.input_data[key] = false + } + params.input_data._value = 0n + params.move_vector = { x: 0, z: 0 } + params.delta = { x: 0, y: 0, z: 0 } + } + } + } else if (!name.includes('tick') && !name.includes('level')) { + console.log('Sending', name) + } + }) + console.info('Client and Server Connected!') + }) + + this.proxy = proxy + } + + listenToBot () { + + } + + close () { + this.proxy?.close() + } +} + +module.exports = { ProxyProvider } +globalThis.logging = true diff --git a/examples/viewer/client/app.css b/examples/viewer/client/app.css new file mode 100644 index 0000000..44c6bea --- /dev/null +++ b/examples/viewer/client/app.css @@ -0,0 +1,22 @@ +html { + overflow: hidden; +} + +html, body { + height: 100%; + margin: 0; + padding: 0; + font-family: sans-serif; +} + +a { + text-decoration: none; +} + +canvas { + height: 100%; + width: 100%; + font-size: 0; + margin: 0; + padding: 0; +} diff --git a/examples/viewer/client/index.html b/examples/viewer/client/index.html new file mode 100644 index 0000000..537be49 --- /dev/null +++ b/examples/viewer/client/index.html @@ -0,0 +1,22 @@ + + + + + Prismarine Viewer + + + + + +
+
Prismarine Viewer
+ +
+
Connecting to 127.0.0.1, port 19132...
+
+
+ + + + + \ No newline at end of file diff --git a/examples/viewer/client/index.js b/examples/viewer/client/index.js new file mode 100644 index 0000000..1b0334d --- /dev/null +++ b/examples/viewer/client/index.js @@ -0,0 +1,4 @@ +const { BotViewer } = require('./BotViewer') + +global.viewer = new BotViewer() +global.viewer.start() diff --git a/examples/viewer/client/movements.js b/examples/viewer/client/movements.js new file mode 100644 index 0000000..d359bea --- /dev/null +++ b/examples/viewer/client/movements.js @@ -0,0 +1,305 @@ +const { Physics, PlayerState } = require('prismarine-physics') +const { performance } = require('perf_hooks') +const { d2r, r2d } = require('./util') +const vec3 = require('vec3') + +const PHYSICS_INTERVAL_MS = 50 +const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000 +const AXES = ['forward', 'back', 'left', 'right'] + +class MovementManager { + // Server auth movement : we send inputs, server calculates position & sends back + serverMovements = false + + constructor (bot) { + this.bot = bot + this.world = bot.world + // Physics tick + this.tick = 0n + } + + get lastPos () { return this.player.entity.position.clone() } + set lastPos (newPos) { this.player.entity.position.set(newPos.x, newPos.y, newPos.z) } + get lastRot () { return vec3(this.player.entity.yaw, this.player.entity.pitch, this.player.entity.headYaw) } + set lastRot (rot) { + if (!isNaN(rot.x)) this.player.entity.yaw = rot.x + if (!isNaN(rot.y)) this.player.entity.pitch = rot.y + if (!isNaN(rot.z)) this.player.entity.headYaw = rot.z + } + + // Ask the server to be in a new position + requestPosition (time, inputState) { + const positionUpdated = !this.lastSentPos || !this.lastPos.equals(this.lastSentPos) + const rotationUpdated = !this.lastSentRot || !this.lastRot.equals(this.lastSentRot) + + if (positionUpdated || rotationUpdated) { + this.lastSentPos = this.lastPos.clone() + // console.log('We computed', this.lastPos) + this.bot.updatePlayerCamera(2, this.lastSentPos, this.playerState.yaw, this.playerState.pitch || this.player.entity.pitch) + if (this.serverMovements) { + globalThis.movePayload = { + pitch: r2d(this.player.entity.pitch), + yaw: r2d(this.player.entity.yaw), + position: { + x: this.lastPos.x, + y: this.lastPos.y + 1.62, + z: this.lastPos.z + }, + move_vector: { // Minecraft coords, N: Z+1, S: Z-1, W: X+1, E: X-1 + x: inputState.left ? 1 : (inputState.right ? -1 : 0), + z: inputState.up ? 1 : (inputState.down ? -1 : 0) + }, + head_yaw: r2d(this.player.entity.yaw), + input_data: inputState, + input_mode: 'mouse', + play_mode: 'screen', + tick: this.tick, + delta: this.lastSentPos?.minus(this.lastPos) ?? { x: 0, y: 0, z: 0 } + } + this.bot.client.queue('player_auth_input', globalThis.movePayload) + } + + this.positionUpdated = false + this.lastSentPos = this.lastPos + this.lastSentRot = this.lastRot + } + } + + init (movementAuthority, position, velocity, yaw = 0, pitch = 0, headYaw = 0) { + if (movementAuthority.includes('server')) { + this.serverMovements = true + } + this.player = { + version: '1.16.1', + inventory: { + slots: [] + }, + entity: { + effects: {}, + position: vec3(position), + velocity: vec3(velocity), + onGround: false, + isInWater: false, + isInLava: false, + isInWeb: false, + isCollidedHorizontally: false, + isCollidedVertically: false, + yaw, + pitch, + headYaw // bedrock + }, + events: { // Control events to send next tick + startSprint: false, + stopSprint: false, + startSneak: false, + stopSneak: false + }, + sprinting: false, + jumpTicks: 0, + jumpQueued: false, + downJump: false + } + + const mcData = require('minecraft-data')('1.16.1') + this.physics = Physics(mcData, this.world) + this.controls = { + forward: false, + back: false, + left: false, + right: false, + jump: false, + sprint: false, + sneak: false + } + } + + // This function should be executed each tick (every 0.05 seconds) + // How it works: https://gafferongames.com/post/fix_your_timestep/ + timeAccumulator = 0 + lastPhysicsFrameTime = null + inputQueue = [] + doPhysics () { + const now = performance.now() + const deltaSeconds = (now - this.lastPhysicsFrameTime) / 1000 + this.lastPhysicsFrameTime = now + + this.timeAccumulator += deltaSeconds + + while (this.timeAccumulator >= PHYSICS_TIMESTEP) { + const q = this.inputQueue.shift() + if (q) { + Object.assign(this.playerState.control, q) + if (!isNaN(q.yaw)) this.player.entity.yaw = q.yaw + if (!isNaN(q.pitch)) this.player.entity.pitch = q.pitch + } + this.playerState = new PlayerState(this.player, this.controls) + this.physics.simulatePlayer(this.playerState, this.world.sync).apply(this.player) + this.lastPos = this.playerState.pos + this.requestPosition(PHYSICS_TIMESTEP, { + ascend: false, + descend: false, + // Players bob up and down in water, north jump is true when going up. + // In water this is only true after the player has reached max height before bobbing back down. + north_jump: this.player.jumpTicks > 0, // Jump + jump_down: this.controls.jump, // Jump + sprint_down: this.controls.sprint, + change_height: false, + jumping: this.controls.jump, // Jump + auto_jumping_in_water: false, + sneaking: false, + sneak_down: false, + up: this.controls.forward, + down: this.controls.back, + left: this.controls.right, + right: this.controls.left, + up_left: false, + up_right: false, + want_up: this.controls.jump, // Jump + want_down: false, + want_down_slow: false, + want_up_slow: false, + sprinting: false, + ascend_scaffolding: false, + descend_scaffolding: false, + sneak_toggle_down: false, + persist_sneak: false, + start_sprinting: this.player.events.startSprint || false, + stop_sprinting: this.player.events.stopSprint || false, + start_sneaking: this.player.events.startSneak || false, + stop_sneaking: this.player.events.stopSneak || false, + // Player is Update Aqatic swimming + start_swimming: false, + // Player stops Update Aqatic swimming + stop_swimming: false, + start_jumping: this.player.jumpTicks === 1, // Jump + start_gliding: false, + stop_gliding: false + }) + this.timeAccumulator -= PHYSICS_TIMESTEP + this.tick++ + } + } + + startPhys () { + console.log('Start phys') + this.physicsLoop = setInterval(() => { + this.doPhysics() + }, PHYSICS_INTERVAL_MS) + } + + get sprinting() { + return this.player.sprinting + } + + set sprinting(val) { + this.player.events.startSprint = val + this.player.events.stopSprint = !val + if (val && !this.player.sprinting) { + this.bot.emit('startSprint') + } else { + this.bot.emit('stopSprint') + } + this.player.sprinting = val + } + + _lastInput = { control: '', time: 0 } + + /** + * Sets the active control state and also keeps track of key toggles. + * @param {'forward' | 'back' | 'left' | 'right' | 'jump' | 'sprint' | 'sneak'} control + * @param {boolean} state + */ + setControlState (control, state, time = Date.now()) { + // HACK ! switch left and right, fixes control issue + if (control === 'left') control = 'right' + else if (control === 'right') control = 'left' + + if (this.controls[control] === state) return + + const isAxis = AXES.includes(control) + let hasOtherAxisKeyDown = false + for (const c of AXES) { + if (this.controls[c] && c != control) { + hasOtherAxisKeyDown = true + } + } + + if (control === 'sprint') { + if (state && hasOtherAxisKeyDown) { // sprint down + a axis movement key + this.sprinting = true + } else if ((!state || !hasOtherAxisKeyDown) && this.sprinting) { // sprint up or movement key up & current sprinting + this.bot.emit('stopSprint') + this.sprinting = false + } + } else if (isAxis && this.controls.sprint) { + if (!state && !hasOtherAxisKeyDown) { + this.sprinting = false + } else if (state && !hasOtherAxisKeyDown) { + this.sprinting = true + } + } else if (control === 'sneak') { + if (state) { + this.player.events.startSneak = true + this.bot.emit('startSneak') + } else { + this.player.events.stopSneak = true + this.bot.emit('stopSneak') + } + } else if (control === 'forward' && this._lastInput.control === 'forward' && (Date.now() - this._lastInput.time) < 100 && !this.controls.sprint) { + // double tap forward within 0.5 seconds, toggle sprint + // this.controls.sprint = true + // this.sprinting = true + } + + this._lastInput = { control, time } + this.controls[control] = state + } + + stopPhys () { + clearInterval(this.physicsLoop) + } + + // Called when a proxy player sends a PlayerInputPacket. We need to apply these inputs tick-by-tick + // as these packets are sent by the client every tick. + pushInputState (state, yaw, pitch) { + const yawRad = d2r(yaw) + const pitchRad = d2r(pitch) + this.inputQueue.push({ + forward: state.up, + back: state.down, // TODO: left and right switched ??? + left: state.right, + right: state.left, + jump: state.jump_down, + sneak: state.sneak_down, + yaw: yawRad, + pitch: pitchRad + }) + // debug + globalThis.debugYaw = [yaw, yawRad] + } + + + // Called when a proxy player sends a PlayerInputPacket. We need to apply these inputs tick-by-tick + // as these packets are sent by the client every tick. + pushCameraControl (state, id = 1) { + let { x, y, z } = state.position + if (id === 1) y -= 1.62 // account for player bb + const adjPos = vec3({ x, y, z }) + // Sneak resyncs the position for easy testing + this.bot.updatePlayerCamera(id, adjPos, d2r(state.yaw), d2r(state.pitch), state.input_data.sneak_down) + } + + // Server gives us a new position + updatePosition (pos, yaw, pitch, headYaw, tick) { + this.lastPos = pos + this.lastRot = { x: yaw, y: pitch, z: headYaw } + if (tick) this.tick = tick + } + + // User has moved the camera. Update the movements stored. + onViewerCameraMove (newYaw, newPitch, newHeadYaw) { + this.lastRot = { x: newYaw, y: newPitch, z: newHeadYaw } + } +} + +module.exports = { MovementManager } diff --git a/examples/viewer/client/preload.js b/examples/viewer/client/preload.js new file mode 100644 index 0000000..58594b5 --- /dev/null +++ b/examples/viewer/client/preload.js @@ -0,0 +1,9 @@ +// Required to detect electron in prismarine-viewer +globalThis.isElectron = true + +// If you need to disable node integration: +// * Node.js APIs will only be avaliable in this file +// * Use this file to load a viewer manager class +// based on one of the examples +// * Expose this class to the global window +// * Interact with the class in your code diff --git a/examples/viewer/client/util.js b/examples/viewer/client/util.js new file mode 100644 index 0000000..c947af9 --- /dev/null +++ b/examples/viewer/client/util.js @@ -0,0 +1,22 @@ +const difference = (o1, o2) => Object.keys(o2).reduce((diff, key) => { + if (o1[key] === o2[key]) return diff + return { + ...diff, + [key]: o2[key] + } +}, {}) + +const diff = (o1, o2) => { const dif = difference(o1, o2); return Object.keys(dif).length ? dif : null } + +const d2r = deg => (180 - (deg < 0 ? (360 + deg) : deg)) * (Math.PI / 180) +const r2d = rad => { + let deg = rad * (180 / Math.PI) + deg = deg % 360 + return 180 - deg +} + +module.exports = { + diff, + d2r, + r2d +} diff --git a/examples/viewer/client/worker.js b/examples/viewer/client/worker.js new file mode 100644 index 0000000..23ff320 --- /dev/null +++ b/examples/viewer/client/worker.js @@ -0,0 +1,2 @@ +// hack for path resolving +require('prismarine-viewer/viewer/lib/worker') diff --git a/examples/viewer/index.js b/examples/viewer/index.js new file mode 100644 index 0000000..954d4b6 --- /dev/null +++ b/examples/viewer/index.js @@ -0,0 +1,44 @@ +const path = require('path') +const { app, BrowserWindow, globalShortcut } = require('electron') + +function createMainWindow() { + const window = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + nodeIntegrationInWorker: true, + contextIsolation: false, + preload: path.join(__dirname, './client/preload.js') + } + }) + + // Open dev tools on load + window.webContents.openDevTools() + + window.loadFile(path.join(__dirname, './client/index.html')) + + window.webContents.on('devtools-opened', () => { + window.focus() + setImmediate(() => { + window.focus() + }) + }) + + return window +} + +app.on('ready', () => { + const win = createMainWindow() + + globalShortcut.register('CommandOrControl+W', () => { + win.webContents.sendInputEvent({ + type: 'keyDown', + keyCode: 'W' + }) + }) +}) + +app.on('window-all-closed', function () { + app.quit() +}) + +app.allowRendererProcessReuse = false diff --git a/examples/viewer/package.json b/examples/viewer/package.json new file mode 100644 index 0000000..9895117 --- /dev/null +++ b/examples/viewer/package.json @@ -0,0 +1,15 @@ +{ + "name": "bedrock-protocol-viewer", + "description": "bedrock-protocol prismarine-viewer example", + "scripts": { + "start": "electron ." + }, + "dependencies": { + "bedrock-protocol": "file:../../", + "browserify-cipher": "^1.0.1", + "electron": "^12.0.2", + "patch-package": "^6.4.7", + "prismarine-physics": "^1.2.2", + "prismarine-viewer": "^1.19.1" + } +} diff --git a/package.json b/package.json index 9be65ba..e6371bd 100644 --- a/package.json +++ b/package.json @@ -23,18 +23,19 @@ "@azure/msal-node": "^1.0.0-beta.6", "@jsprismarine/jsbinaryutils": "^2.1.8", "@xboxreplay/xboxlive-auth": "^3.3.3", - "aes-js": "^3.1.2", "asn1": "^0.2.4", - "bedrock-provider": "^0.1.1", + "browserify-cipher": "^1.0.1", + "bedrock-provider": "^1.0.0", "debug": "^4.3.1", "ec-pem": "^0.18.0", "jsonwebtoken": "^8.5.1", "jsp-raknet": "github:extremeheat/raknet#client", + "leveldb-zlib": "0.0.26", "minecraft-folder-path": "^1.1.0", "node-fetch": "^2.6.1", "prismarine-nbt": "^1.5.0", "protodef": "^1.11.0", - "raknet-native": "^0.2.0", + "raknet-native": "^1.0.0", "uuid-1345": "^1.0.2" }, "devDependencies": { diff --git a/src/auth/login.js b/src/auth/login.js index 55c7ef9..49913b4 100644 --- a/src/auth/login.js +++ b/src/auth/login.js @@ -3,6 +3,7 @@ const JWT = require('jsonwebtoken') const DataProvider = require('../../data/provider') const ecPem = require('ec-pem') const curve = 'secp384r1' +const { nextUUID } = require('../datatypes/util') module.exports = (client, server, options) => { const skinGeom = fs.readFileSync(DataProvider(options.protocolVersion).getPath('skin_geom.txt'), 'utf-8') @@ -45,10 +46,10 @@ module.exports = (client, server, options) => { CapeImageHeight: 0, CapeImageWidth: 0, CapeOnClassicSkin: false, - ClientRandomId: 1, // TODO make biggeer + ClientRandomId: Date.now(), CurrentInputMode: 1, DefaultInputMode: 1, - DeviceId: '2099de18-429a-465a-a49b-fc4710a17bb3', // TODO random + DeviceId: nextUUID(), DeviceModel: '', DeviceOS: client.session?.deviceOS || 7, GameVersion: options.version || '1.16.201', @@ -64,7 +65,7 @@ module.exports = (client, server, options) => { // inside of PlayFab. PlayFabId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0', // 1.16.210 PremiumSkin: false, - SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343701', + SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343702', ServerAddress: `${options.hostname}:${options.port}`, SkinAnimationData: '', SkinColor: '#ffffcd96', diff --git a/src/auth/loginVerify.js b/src/auth/loginVerify.js index e1c2743..0809269 100644 --- a/src/auth/loginVerify.js +++ b/src/auth/loginVerify.js @@ -1,5 +1,6 @@ const JWT = require('jsonwebtoken') const constants = require('./constants') +const debug = require('debug')('minecraft-protocol') module.exports = (client, server, options) => { // Refer to the docs: @@ -19,14 +20,14 @@ module.exports = (client, server, options) => { let finalKey = null // console.log(pubKey) for (const token of chain) { - const decoded = JWT.verify(token, pubKey, { algorithms: 'ES384' }) + const decoded = JWT.verify(token, pubKey, { algorithms: ['ES384'] }) // console.log('Decoded', decoded) // Check if signed by Mojang key const x5u = getX5U(token) if (x5u === constants.PUBLIC_KEY && !data.extraData?.XUID) { // didVerify = true - console.log('verified with mojang key!', x5u) + debug('Verified client with mojang key', x5u) } // TODO: Handle `didVerify` = false @@ -41,7 +42,7 @@ module.exports = (client, server, options) => { function verifySkin (publicKey, token) { const pubKey = mcPubKeyToPem(publicKey) - const decoded = JWT.verify(token, pubKey, { algorithms: 'ES384' }) + const decoded = JWT.verify(token, pubKey, { algorithms: ['ES384'] }) return decoded } diff --git a/src/client/tokens.js b/src/client/tokens.js index af82f45..6603df9 100644 --- a/src/client/tokens.js +++ b/src/client/tokens.js @@ -244,7 +244,7 @@ class MinecraftTokenManager { const token = this.cache.mca debug('[mc] token cache', this.cache) if (!token) return - console.log('TOKEN', token) + debug('Auth token', token) const jwt = token.chain[0] const [header, payload, signature] = jwt.split('.').map(k => Buffer.from(k, 'base64')) // eslint-disable-line diff --git a/src/connection.js b/src/connection.js index a9061c0..f4476bc 100644 --- a/src/connection.js +++ b/src/connection.js @@ -15,10 +15,19 @@ const ClientStatus = { } class Connection extends EventEmitter { - status = ClientStatus.Disconnected + #status = ClientStatus.Disconnected q = [] q2 = [] + get status () { + return this.#status + } + + set status (val) { + debug('* new status', val) + this.#status = val + } + versionLessThan (version) { if (typeof version === 'string') { return Versions[version] < this.options.protocolVersion @@ -117,7 +126,7 @@ class Connection extends EventEmitter { sendEncryptedBatch (batch) { const buf = batch.stream.getBuffer() - debug('Sending encrypted batch', batch) + // debug('Sending encrypted batch', batch) this.encrypt(buf) } @@ -132,7 +141,7 @@ class Connection extends EventEmitter { this.outLog('Enc buf', buf) const packet = Buffer.concat([Buffer.from([0xfe]), buf]) // add header - this.outLog('Sending wrapped encrypted batch', packet) + // this.outLog('Sending wrapped encrypted batch', packet) this.sendMCPE(packet) } diff --git a/src/datatypes/BatchPacket.js b/src/datatypes/BatchPacket.js index cdfb292..8c1094c 100644 --- a/src/datatypes/BatchPacket.js +++ b/src/datatypes/BatchPacket.js @@ -38,10 +38,8 @@ class BatchPacket { encode () { const buf = this.stream.getBuffer() - console.log('Encoding payload', buf) const def = Zlib.deflateRawSync(buf, { level: this.compressionLevel }) const ret = Buffer.concat([Buffer.from([0xfe]), def]) - console.log('Compressed', ret) return ret } diff --git a/src/datatypes/util.js b/src/datatypes/util.js index 4a14e31..b080577 100644 --- a/src/datatypes/util.js +++ b/src/datatypes/util.js @@ -39,4 +39,8 @@ function uuidFrom (string) { return UUID.v3({ namespace: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: string }) } -module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom } +function nextUUID () { + return uuidFrom(Date.now().toString()) +} + +module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom, nextUUID } diff --git a/src/rak.js b/src/rak.js index e4a203d..912df2d 100644 --- a/src/rak.js +++ b/src/rak.js @@ -5,8 +5,9 @@ const Reliability = require('jsp-raknet/protocol/reliability') const RakClient = require('jsp-raknet/client') const ConnWorker = require('./rakWorker') const { waitFor } = require('./datatypes/util') +const ServerName = require('./server/advertisement') try { - var { Client, Server, PacketPriority, PacketReliability, McPingMessage } = require('raknet-native') // eslint-disable-line + var { Client, Server, PacketPriority, PacketReliability } = require('raknet-native') // eslint-disable-line } catch (e) { console.debug('[raknet] native not found, using js', e) } @@ -19,16 +20,17 @@ class RakNativeClient extends EventEmitter { this.onCloseConnection = () => { } this.onEncapsulated = () => { } - this.raknet = new Client(options.hostname, options.port, 'minecraft') + this.raknet = new Client(options.hostname, options.port, { protocolVersion: 10 }) this.raknet.on('encapsulated', ({ buffer, address }) => { this.onEncapsulated(buffer, address) }) - this.raknet.on('connected', () => { + + this.raknet.on('connect', () => { this.connected = true this.onConnected() }) - this.raknet.on('disconnected', ({ reason }) => { + this.raknet.on('disconnect', ({ reason }) => { this.connected = false this.onCloseConnection(reason) }) @@ -64,16 +66,17 @@ class RakNativeClient extends EventEmitter { } class RakNativeServer extends EventEmitter { - constructor (options = {}) { + constructor (options = {}, server) { super() this.onOpenConnection = () => { } this.onCloseConnection = () => { } this.onEncapsulated = () => { } this.raknet = new Server(options.hostname, options.port, { maxConnections: options.maxConnections || 3, - minecraft: {}, - message: new McPingMessage().toBuffer() + protocolVersion: 10, + message: ServerName.getServerName(server) }) + // TODO: periodically update the server name until we're closed this.raknet.on('openConnection', (client) => { client.sendReliable = function (buffer, immediate) { @@ -90,7 +93,6 @@ class RakNativeServer extends EventEmitter { }) this.raknet.on('encapsulated', ({ buffer, address }) => { - // console.log('ENCAP',thingy) this.onEncapsulated(buffer, address) }) } diff --git a/src/relay.js b/src/relay.js index ed78a09..813ec05 100644 --- a/src/relay.js +++ b/src/relay.js @@ -2,8 +2,8 @@ const { Client } = require('./client') const { Server } = require('./server') const { Player } = require('./serverPlayer') -const debug = require('debug')('minecraft-protocol relay') -const { serialize } = require('./datatypes/util') +const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol relay') +// const { serialize } = require('./datatypes/util') /** @typedef {{ hostname: string, port: number, auth: 'client' | 'server' | null, destination?: { hostname: string, port: number } }} Options */ @@ -22,10 +22,10 @@ class RelayPlayer extends Player { }) this.downQ = [] this.upQ = [] - this.upInLog = (...msg) => console.info('** Backend -> Proxy', ...msg) - this.upOutLog = (...msg) => console.info('** Proxy -> Backend', ...msg) - this.downInLog = (...msg) => console.info('** Client -> Proxy', ...msg) - this.downOutLog = (...msg) => console.info('** Proxy -> Client', ...msg) + this.upInLog = (...msg) => console.debug('** Backend -> Proxy', ...msg) + this.upOutLog = (...msg) => console.debug('** Proxy -> Backend', ...msg) + this.downInLog = (...msg) => console.debug('** Client -> Proxy', ...msg) + this.downOutLog = (...msg) => console.debug('** Proxy -> Client', ...msg) if (!server.options.logging) { this.upInLog = () => { } @@ -52,11 +52,11 @@ class RelayPlayer extends Player { this.downQ.push(packet) return } - this.upInLog('Recv packet', packet) + // this.upInLog('Recv packet', packet) const des = this.server.deserializer.parsePacketBuffer(packet) const name = des.data.name const params = des.data.params - this.upInLog('~ Bounce B->C', name, serialize(params).slice(0, 100)) + // this.upInLog('~ Bounce B->C', name, serialize(params).slice(0, 100)) // this.upInLog('~ ', des.buffer) if (name === 'play_status' && params.status === 'login_success') return // We already sent this, this needs to be sent ASAP or client will disconnect @@ -72,6 +72,8 @@ class RelayPlayer extends Player { this.queue(name, params) // this.sendBuffer(packet) + + this.emit('clientbound', des.data) } // Send queued packets to the connected client @@ -105,7 +107,7 @@ class RelayPlayer extends Player { return } this.flushUpQueue() // Send queued packets - this.downInLog('Recv packet', packet) + // this.downInLog('Recv packet', packet) // TODO: If we fail to parse a packet, proxy it raw and log an error const des = this.server.deserializer.parsePacketBuffer(packet) @@ -129,6 +131,7 @@ class RelayPlayer extends Player { this.downInLog('Relaying', des.data) this.upstream.sendBuffer(packet) } + this.emit('serverbound', des.data) } else { super.readPacket(packet) } @@ -162,6 +165,8 @@ class Relay extends Server { ds.flushUpQueue() console.log('Connected to upstream server') client.readPacket = (packet) => ds.readUpstream(packet) + + this.emit('join', /* client connected to proxy */ ds, /* backend server */ client) }) this.upstreams.set(clientAddr.hash, client) } @@ -183,7 +188,7 @@ class Relay extends Server { const player = new this.RelayPlayer(this, conn) console.debug('New connection from', conn.address) this.clients[conn.address] = player - this.emit('connect', { client: player }) + this.emit('connect', player) this.openUpstreamConnection(player, conn.address) } } diff --git a/src/server.js b/src/server.js index bf440d3..c2dcdf7 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,7 @@ const { createDeserializer, createSerializer } = require('./transforms/serialize const { Player } = require('./serverPlayer') const { RakServer } = require('./rak') const Options = require('./options') -const debug = require('debug')('minecraft-protocol') +const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol') class Server extends EventEmitter { constructor (options) { @@ -62,6 +62,7 @@ class Server extends EventEmitter { this.raknet.onOpenConnection = this.onOpenConnection this.raknet.onCloseConnection = this.onCloseConnection this.raknet.onEncapsulated = this.onEncapsulated + return { hostname, port } } close (disconnectReason) { diff --git a/src/server/advertisement.js b/src/server/advertisement.js new file mode 100644 index 0000000..0b9a401 --- /dev/null +++ b/src/server/advertisement.js @@ -0,0 +1,39 @@ +class ServerName { + motd = 'Bedrock Protocol Server' + name = 'bedrock-protocol' + protocol = 408 + version = '1.16.20' + players = { + online: 0, + max: 5 + } + + gamemode = 'Creative' + serverId = '0' + + toString (version) { + return [ + 'MCPE', + this.motd, + this.protocol, + this.version, + this.players.online, + this.players.max, + this.serverId, + this.name, + this.gamemode + ].join(';') + ';' + } + + toBuffer (version) { + const str = this.toString(version) + return Buffer.concat([Buffer.from([0, str.length]), Buffer.from(str)]) + } +} + +module.exports = { + ServerName, + getServerName (client) { + return new ServerName().toBuffer() + } +} diff --git a/src/serverPlayer.js b/src/serverPlayer.js index b11d8f4..d2a6d93 100644 --- a/src/serverPlayer.js +++ b/src/serverPlayer.js @@ -57,7 +57,7 @@ class Player extends Connection { // TODO: disconnect user throw new Error('Failed to verify user') } - console.log('Verified user', 'got pub key', key, userData) + debug('Verified user pub key', key, userData) this.emit('login', { user: userData.extraData }) // emit events for user this.emit('server.client_handshake', { key }) // internal so we start encryption diff --git a/src/transforms/encryption.js b/src/transforms/encryption.js index 41432c1..1d4d294 100644 --- a/src/transforms/encryption.js +++ b/src/transforms/encryption.js @@ -1,7 +1,7 @@ const { Transform } = require('readable-stream') const crypto = require('crypto') -const aesjs = require('aes-js') const Zlib = require('zlib') +if (globalThis.isElectron) var { CipherCFB8 } = require('raknet-native') // eslint-disable-line const CIPHER_ALG = 'aes-256-cfb8' @@ -22,32 +22,23 @@ function createDecipher (secret, initialValue) { class Cipher extends Transform { constructor (secret, iv) { super() - this.aes = new aesjs.ModeOfOperation.cfb(secret, iv, 1) // eslint-disable-line new-cap + this.aes = new CipherCFB8(secret, iv) } _transform (chunk, enc, cb) { - try { - const res = this.aes.encrypt(chunk) - cb(null, res) - } catch (e) { - cb(e) - } + const ciphered = this.aes.cipher(chunk) + cb(null, ciphered) } } class Decipher extends Transform { constructor (secret, iv) { super() - this.aes = new aesjs.ModeOfOperation.cfb(secret, iv, 1) // eslint-disable-line new-cap + this.aes = new CipherCFB8(secret, iv) } _transform (chunk, enc, cb) { - try { - const res = this.aes.decrypt(chunk) - cb(null, res) - } catch (e) { - cb(e) - } + cb(null, this.aes.decipher(chunk)) } } @@ -70,10 +61,12 @@ function createEncryptor (client, iv) { // The send counter is represented as a little-endian 64-bit long and incremented after each packet. function process (chunk) { - const buffer = Zlib.deflateRawSync(chunk, { level: 7 }) - const packet = Buffer.concat([buffer, computeCheckSum(buffer, client.sendCounter, client.secretKeyBytes)]) - client.sendCounter++ - client.cipher.write(packet) + Zlib.deflateRaw(chunk, { level: 7 }, (err, buffer) => { + if (err) throw err + const packet = Buffer.concat([buffer, computeCheckSum(buffer, client.sendCounter, client.secretKeyBytes)]) + client.sendCounter++ + client.cipher.write(packet) + }) } client.cipher.on('data', client.onEncryptedPacket) diff --git a/tools/startVanillaServer.js b/tools/startVanillaServer.js index 07587ae..a409fca 100644 --- a/tools/startVanillaServer.js +++ b/tools/startVanillaServer.js @@ -104,7 +104,7 @@ async function startServerAndWait (version, withTimeout, options) { if (!module.parent) { // if (process.argv.length < 3) throw Error('Missing version argument') - startServer(process.argv[2] || '1.16.201') + startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3] } : undefined) } module.exports = { fetchLatestStable, startServer, startServerAndWait }