Compare commits

..

No commits in common. "master" and "3.2.0" have entirely different histories.

89 changed files with 35671 additions and 1963 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
examples/viewer

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
data/*/*.json linguist-generated

3
.github/FUNDING.yml vendored
View file

@ -1,3 +0,0 @@
github: PrismarineJS
open_collective: prismarinejs
custom: https://rysolv.com/repos/detail/74691b23-938d-4b2f-b65a-5c47bf5b3f0f

View file

@ -1,139 +0,0 @@
// Automatic version update checker for Minecraft bedrock edition.
const fs = require('fs')
const cp = require('child_process')
const helper = require('gh-helpers')()
const latestVesionEndpoint = 'https://itunes.apple.com/lookup?bundleId=com.mojang.minecraftpe&time=' + Date.now()
const changelogURL = 'https://feedback.minecraft.net/hc/en-us/sections/360001186971-Release-Changelogs'
// Relevant infomation for us is:
// "version": "1.17.10",
// "currentVersionReleaseDate": "2021-07-13T15:35:49Z",
// "releaseNotes": "What's new in 1.17.10:\nVarious bug fixes",
function buildFirstIssue (title, result, externalPatches) {
let commitData = ''
let protocolVersion = '?'
const date = new Date(result.currentVersionReleaseDate).toUTCString()
for (const name in externalPatches) {
const [patches, diff] = externalPatches[name]
commitData += '### ' + name + '\n'
for (const [name, url] of patches) {
commitData += `<a href="${url}">${name}</a>\n`
}
if (diff) commitData += `\n**[See the diff between *${result.currentVersionReleaseDate}* and now](${diff})**\n`
else commitData += '\n(No changes so far)\n'
}
try { protocolVersion = getProtocolVersion() } catch (e) { console.log(e) }
return {
title,
body: `
A new Minecraft Bedrock version is available (as of ${date}), version **${result.version}**
## Official Changelog
* ${result.releaseNotes} *(via App Store)*
* ${changelogURL}
## 3rd party protocol patches
${commitData}
## Protocol Details
(I will close this issue automatically if "${result.version}" is added to index.d.ts on "master" and there are no X's below)
<table>
<tr><td><b>Name</b></td><td>${result.version}</td>
<tr><td><b>Protocol ID</b></td><td>${protocolVersion}</td>
<!-- TODO ... automatically fetch server, test and grab relevant information and dump
<tr><td><b>Partly Already Compatible</b></td><td></td>
<tr><td><b>Protocol Dumpers Work</b></td><td></td>
-->
</table>
-----
🤖 I am a bot, I check for updates every 2 hours without a trigger. You can close this issue to prevent any further updates.
`
}
}
function getCommitsInRepo (repo, containing, since) {
const endpoint = `https://api.github.com/repos/${repo}/commits`
console.log('Getting', endpoint)
cp.execSync(`curl -L ${endpoint} -o commits.json`, { stdio: 'inherit', shell: true })
const commits = JSON.parse(fs.readFileSync('./commits.json', 'utf-8'))
const relevant = []
for (const commit of commits) {
if (commit.commit.message.includes(containing)) {
console.log('commit url', commit.html_url)
relevant.push([commit.commit.message, commit.html_url])
}
}
if (since) {
cp.execSync(`curl -L ${endpoint}?since=${since} -o commits.json`, { stdio: 'inherit', shell: true })
const commits = JSON.parse(fs.readFileSync('./commits.json', 'utf-8'))
if (commits.length) {
const head = commits[0].sha
const tail = commits[commits.length - 1].sha
return [relevant, `https://github.com/${repo}/compare/${tail}..${head}`]
}
}
return [relevant]
}
function getProtocolVersion () {
if (!fs.existsSync('./ProtocolInfo.php')) cp.execSync('curl -LO https://raw.githubusercontent.com/pmmp/PocketMine-MP/stable/src/pocketmine/network/mcpe/protocol/ProtocolInfo.php', { stdio: 'inherit', shell: true })
const currentApi = fs.readFileSync('./ProtocolInfo.php', 'utf-8')
const [, latestProtocolVersion] = currentApi.match(/public const CURRENT_PROTOCOL = (\d+);/)
return latestProtocolVersion
}
async function fetchLatest () {
if (!fs.existsSync('./results.json')) cp.execSync(`curl -L "${latestVesionEndpoint}" -o results.json`, { stdio: 'inherit', shell: true })
const json = require('./results.json')
const result = json.results[0]
// console.log(json)
if (!fs.existsSync('./index.d.ts')) cp.execSync('curl -LO https://raw.githubusercontent.com/PrismarineJS/bedrock-protocol/master/index.d.ts', { stdio: 'inherit', shell: true })
const currentApi = fs.readFileSync('./index.d.ts', 'utf-8')
const supportedVersions = currentApi.match(/type Version = ([^\n]+)/)[1].replace(/\||'/g, ' ').split(' ').map(k => k.trim()).filter(k => k.length)
console.log(supportedVersions)
let { version, currentVersionReleaseDate, releaseNotes } = result
console.log(version, currentVersionReleaseDate, releaseNotes)
const title = `Support Minecraft ${result.version}`
const issueStatus = await helper.findIssue({ titleIncludes: title }) || {}
if (supportedVersions.includes(version)) {
if (issueStatus.isOpen) {
helper.close(issueStatus.id, `Closing as ${version} is now supported`)
}
console.log('Latest version is supported.')
return
}
if (issueStatus.isClosed) {
// We already made an issue, but someone else already closed it, don't do anything else
console.log('I already made an issue, but it was closed')
return
}
version = version.replace('.0', '')
const issuePayload = buildFirstIssue(title, result, {
PocketMine: getCommitsInRepo('pmmp/PocketMine-MP', version, currentVersionReleaseDate),
gophertunnel: getCommitsInRepo('Sandertv/gophertunnel', version, currentVersionReleaseDate),
CloudburstMC: getCommitsInRepo('CloudburstMC/Protocol', version, currentVersionReleaseDate)
})
if (issueStatus.isOpen) {
helper.updateIssue(issueStatus.id, issuePayload)
} else {
helper.createIssue(issuePayload)
}
fs.writeFileSync('./issue.md', issuePayload.body)
console.log('OK, wrote to ./issue.md', issuePayload)
}
fetchLatest()

View file

@ -5,32 +5,21 @@ on:
branches: [ '*', '!gh-pages' ] branches: [ '*', '!gh-pages' ]
pull_request: pull_request:
branches: [ '*', '!gh-pages' ] branches: [ '*', '!gh-pages' ]
workflow_dispatch:
inputs:
via:
description: 'trigger origin'
required: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy: strategy:
fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest] node-version: [14.x]
node-version: [22.x]
runs-on: ${{ matrix.os }}
timeout-minutes: 14
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
# Old versions of bedrock use old libssl that Ubuntu no longer ships with; need manual install
- name: (Linux) Install libssl 1.1
if: runner.os == 'Linux'
run: |
wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb
sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb
- run: npm install - run: npm install
- run: npm test - run: npm test

View file

@ -1,22 +0,0 @@
name: Repo Commands
on:
issue_comment: # Handle comment commands
types: [created]
pull_request_target: # Handle renamed PRs
types: [edited]
jobs:
comment-trigger:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Run command handlers
uses: PrismarineJS/prismarine-repo-actions@master
with:
# NOTE: You must specify a Personal Access Token (PAT) with repo access here. While you can use the default GITHUB_TOKEN, actions taken with it will not trigger other actions, so if you have a CI workflow, commits created by this action will not trigger it.
token: ${{ secrets.PAT_PASSWORD }}
# See `Options` section below for more info on these options
install-command: npm install
/fixlint.fix-command: npm run fix

View file

@ -1,24 +0,0 @@
name: Update Helper
on:
workflow_dispatch:
schedule:
- cron: "0 */2 * * *"
jobs:
helper:
name: update-checker
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@master
- name: Set up Node.js
uses: actions/setup-node@master
with:
node-version: 22.0.0
- name: Install Github Actions helper
run: npm i gh-helpers
# The env vars contain the relevant trigger information, so we don't need to pass it
- name: Runs helper
run: cd .github/helper-bot && node index.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View file

@ -2,7 +2,10 @@ node_modules/
npm-debug.log npm-debug.log
package-lock.json package-lock.json
__* __*
src/**/*.json
# Runtime generated data # Runtime generated data
data/ data/**/sample
tools/bds* data/**/read.js
tools/pmmp* data/**/write.js
data/**/size.js
tools/bds*

View file

@ -1,10 +1,7 @@
node_modules/ node_modules/
npm-debug.log npm-debug.log
__* __*
src/**/*.json
# Runtime generated data # Runtime generated data
data/ data/**/sample
tools/bds* tools/bds*
# Extra data
examples
test
.github

View file

@ -1,31 +1,16 @@
CONTRIBUTING.md CONTRIBUTING.md
Contributions are always welcome :). If you have any questions, please discuss on the Discord or in a Discussion. Contributions are always welcome :)
## Updating ## Updating
Good sources for the Minecraft bedrock protocol are [gophertunnel](https://github.com/Sandertv/gophertunnel/tree/master/minecraft/protocol/packet), [ClouburstMC's protocol library](https://github.com/CloudburstMC/Protocol) and [PocketMine](https://github.com/pmmp/PocketMine-MP/tree/stable/src/pocketmine/network/mcpe/protocol). Good sources for the Minecraft bedrock protocol are [gophertunnel](https://github.com/Sandertv/gophertunnel/tree/master/minecraft/protocol/packet), [ClouburstMC's protocol library](https://github.com/CloudburstMC/Protocol) and [PocketMine](https://github.com/pmmp/PocketMine-MP/tree/stable/src/pocketmine/network/mcpe/protocol).
Protocol updates need to happen in two places: in minecraft-data to update the protocol schema (the actual data structures for the packets) and here in the protocol library side. If no changes to the underlying protocol are made aside from packet structure changes (add, remove, modify packets) then the only change needed in bedrock-protocol is to update the README documentation and some constants in `src/options.js` (update the CURRENT_VERSION).
Steps to update: Steps to update:
* Update the protocol data in minecraft-data : see the instructions [here](https://github.com/PrismarineJS/minecraft-data/blob/master/doc/bedrock.md). * Add the version to src/options.js
* Find the relevant changes to the protocol for the current version * Open [data/latest/proto.yml](https://github.com/PrismarineJS/bedrock-protocol/tree/new/data/latest) and add, remove or modify the updated packets (see the [Packet serialization](#Packet_serialization) notes at the bottom for info on syntax)
* Update the [.YML files](https://github.com/PrismarineJS/minecraft-data/tree/master/data/bedrock/latest) in minecraft-data accordingly (see the [Packet serialization](#Packet_serialization) notes at the bottom here for info on syntax) * Save and make sure to update the !version field at the top of the file
* Then follow the steps to build the protocol .YML files into JSON * Run `npm run build` and `npm test` to test
* Do a release of the minecraft-data package
* Add the version to `src/options.js` here
* Run `npm run build` and `npm test` to test that everything is OK
### Development
For development purposes, you can easily alter the protocol locally without a remote minecraft-data release :
* Run `npm install` on the root of this repo after git cloning
* Open `node_modules/minecraft-data/minecraft-data/data/bedrock/latest/` and update the .YML files as you need, following the schema at the bottom (make sure to update '!version' if you are changing version)
* Go back to the root of this repo and run `npm run build`.
* Then `npm test` ; the protocol changes should be automatically applied
For example, [here](https://github.com/PrismarineJS/minecraft-data/pull/467/files) is a PR for the update to 1.17.30 in minecraft-data - [here](https://github.com/PrismarineJS/bedrock-protocol/pull/150/files) is an accompanying change for bedrock-protocol.
## Code structure ## Code structure
@ -110,39 +95,37 @@ The above roughly translates to the following JavaScript code to read a packet:
```js ```js
function read_position(stream) { function read_position(stream) {
const ret = {} const ret = {}
ret.x = stream.readLI32() ret.x = stream.readSignedInt32LE()
ret.z = stream.readLU32() ret.z = stream.readUnsignedInt32LE()
ret.y = stream.readLF32() ret.y = stream.readFloat32LE()
return ret return ret
} }
function read_player_position(stream) { function read_player_position(stream) {
const ret = {} const ret = {}
ret.on_ground = Boolean(stream.readU8()) ret.on_ground = Boolean(stream.readU8())
ret.position = read_position(stream) ret.position = read_player_position(stream)
let __movement_reason = stream.readU8() let __movement_reason = stream.readU8()
let movement_reason = { 0: 'player_jump', 1: 'player_autojump', 2: 'player_sneak', 3: 'player_sprint', 4: 'player_fall' }[__movement_reason] let movement_reason = { 0: 'player_jump', 1: 'player_autojump', 2: 'player_sneak', 3: 'player_sprint', 4: 'player_fall' }[__movement_reason]
switch (movement_reason) { switch (movement_reason) {
case 'player_jump': case 'player_jump':
case 'player_autojump': case 'player_autojump':
ret.original_position = read_position(stream) ret.original_position = read_player_position(stream)
ret.jump_tick = stream.readLI64() ret.jump_tick = stream.readInt64LE(stream)
break break
case 'player_fall': case 'player_fall':
ret.original_position = read_position(stream) ret.original_position = read_player_position(stream)
break break
default: break default: break
} }
ret.player_hunger = undefined ret.player_hunger = undefined
if (movement_reason == 'player_sprint') ret.player_hunger = stream.readU8() if (movement_reason == 'player_sprint') ret.player_hunger = stream.readU8()
ret.last_positions = [] ret.last_positions = []
let __latest_positions_len = stream.readUnsignedVarInt() for (let i = 0; i < stream.readUnsignedVarInt(); i++) {
for (let i = 0; i < __latest_positions_len; i++) {
ret.last_positions.push(read_player_position(stream)) ret.last_positions.push(read_player_position(stream))
} }
ret.keys_down = [] ret.keys_down = []
let __keys_down_len = stream.readZigZagVarInt() for (let i = 0; i < stream.readZigZagVarInt(); i++) {
for (let i = 0; i < __keys_down_len; i++) {
const ret1 = {} const ret1 = {}
ret1.up = Boolean(stream.readU8()) ret1.up = Boolean(stream.readU8())
ret1.down = Boolean(stream.readU8()) ret1.down = Boolean(stream.readU8())

View file

@ -1,250 +1,10 @@
## 3.49.0
* [1.21.111 (#649)](https://github.com/PrismarineJS/bedrock-protocol/commit/b48518a6e79e72101fe7136433cbd6277339fc5c) (thanks @Slauh)
* [Skin Data Changes (#647)](https://github.com/PrismarineJS/bedrock-protocol/commit/407756b93880cdda4fdbff194fc4163ceedf4e82) (thanks @thejfkvis)
## 3.48.1
* [Update login client skinData (#635)](https://github.com/PrismarineJS/bedrock-protocol/commit/6b1474d2c6f93b47dee9d4816de59579f82ed5a9) (thanks @TSL534)
## 3.48.0
* [1.21.100 (#632)](https://github.com/PrismarineJS/bedrock-protocol/commit/06fb3de3a0023d03201dbcee7e9178c269462766) (thanks @extremeheat)
## 3.47.0
* [1.21.93 support (#623)](https://github.com/PrismarineJS/bedrock-protocol/commit/14daa2d95aac90ffcc7b42d625e270020ec2f162) (thanks @CreeperG16)
## 3.46.0
* [1.21.90 support (#617)](https://github.com/PrismarineJS/bedrock-protocol/commit/c66cdd3d62d2fa9c581693d8c70d7b41f355b63e) (thanks @CreeperG16)
## 3.45.0
* [1.21.80 (#602)](https://github.com/PrismarineJS/bedrock-protocol/commit/e71fd513ddbd432983f221980080b61e11576965) (thanks @extremeheat)
## 3.44.0
* [1.21.70 (#594)](https://github.com/PrismarineJS/bedrock-protocol/commit/065f41db8cfc8cbd8106bd9e376c899ec25f3f77) (thanks @CreeperG16)
## 3.43.1
* [Fix server not correctly removing clients (#588)](https://github.com/PrismarineJS/bedrock-protocol/commit/47f342ca958ba87a7719783bd5c855cebdd4aa65) (thanks @EntifiedOptics)
## 3.43.0
* [1.21.60 support (#570)](https://github.com/PrismarineJS/bedrock-protocol/commit/eeb5e47e35f31cc571a9a8a491f5a89b27e637f1) (thanks @CreeperG16)
* [Fix version feature handling (#572)](https://github.com/PrismarineJS/bedrock-protocol/commit/0ed8e32be85f05926cd97d5f0317ed004ae5eefa) (thanks @ItsMax123)
## 3.42.3
* [Fix Server `maxPlayers` option (#565)](https://github.com/PrismarineJS/bedrock-protocol/commit/38dc5a256105a44786d5455570d5a130e64ef561) (thanks @extremeheat)
## 3.42.2
* Fix missing type serialization error
## 3.42.1
* [Add 1.21.40 login fields (#553)](https://github.com/PrismarineJS/bedrock-protocol/commit/24d3200181c060162b04fb233fef6e0d6d1a93aa) (thanks @extremeheat)
* [Remove protodef varint types (#552)](https://github.com/PrismarineJS/bedrock-protocol/commit/347e303ce422bdb6f6dfd4cba57d7d3937214707) (thanks @extremeheat)
## 3.42.0
* [1.21.50 support](https://github.com/PrismarineJS/bedrock-protocol/commit/1c0836bff03d50cb12a3e45763eac6c9f605e00c) (thanks @extremeheat)
* [Dynamic compression & batch header (#544)](https://github.com/PrismarineJS/bedrock-protocol/commit/911e0e890febc00102cd1e5406731e66f7bad0ef) (thanks @LucienHH)
## 3.41.0
* [1.21.42 support](https://github.com/PrismarineJS/bedrock-protocol/commit/dd5c4de4f2624c3654af66e9a40a65eb13de0850) (thanks @CreeperG16)
## 3.40.0
* [1.21.30 support (#527)](https://github.com/PrismarineJS/bedrock-protocol/commit/fc30c96135ec20dca1257f702152cba61d4a59be) (thanks @pokecosimo)
* [Update tests (#528)](https://github.com/PrismarineJS/bedrock-protocol/commit/cb530c8b45bf505f75e0e39241d88085c5564ae8) (thanks @extremeheat)
## 3.39.0
* [1.21.20](https://github.com/PrismarineJS/bedrock-protocol/commit/3be55777fab4949179d3a7108ee29bbd8fada5a7) (thanks @extremeheat)
* [update disconnect packet](https://github.com/PrismarineJS/bedrock-protocol/commit/4c3f62567e0f6ce20b70ea23238fce8606011e95) (thanks @extremeheat)
## 3.38.0
* [Support 1.21.2, and add missing versions to type definitions (#510)](https://github.com/PrismarineJS/bedrock-protocol/commit/5d3986924d3f262708d7c7e55a7f410f12c7903c) (thanks @CreeperG16)
* [Fix example in README.md for 1.21 (#506)](https://github.com/PrismarineJS/bedrock-protocol/commit/c4593aa355d6ce9e2ac65cc2102cd9285a6b6449) (thanks @Ant767)
* [Don't send now deprecated tick sync packets on 1.21 and newer (#504)](https://github.com/PrismarineJS/bedrock-protocol/commit/84c5231b92df9f5f1a09b29a05e7abfed62f1c2b) (thanks @w0ahL)
## 3.37.0
* [Support 1.21.0](https://github.com/PrismarineJS/bedrock-protocol/commit/5b2d78792c9b4c070d727a9028a6b3a266483e1c) (thanks @CreeperG16)
* [Fix typo in types (#501)](https://github.com/PrismarineJS/bedrock-protocol/commit/16e15d80a5084a19ed2fbabc023789ee38922b3a) (thanks @Kaaaaii)
## 3.36.0
* [Support 1.20.80](https://github.com/PrismarineJS/bedrock-protocol/commit/bd32aa8d04555fa2fdc4ecd6abbeb6124e2ae8bb) (thanks @extremeheat)
## 3.35.0
* [Support 1.20.71](https://github.com/PrismarineJS/bedrock-protocol/commit/d8e707112acc038b6c9564d9a21b2f977326e47f) (thanks @extremeheat)
* [Note `npm update` command in readme](https://github.com/PrismarineJS/bedrock-protocol/commit/ab93d0d0824bd0ace250fb73f703dc7b60ecd780) (thanks @extremeheat)
## 3.34.0
* [1.20.61 support (#480)](https://github.com/PrismarineJS/bedrock-protocol/commit/c278a03f952d23320b80f8c09b6372d41eeff26a) (thanks @extremeheat)
* [Compressor handling update for 1.20.60 (#479)](https://github.com/PrismarineJS/bedrock-protocol/commit/d3161badc65f2eba4b6e7c9e974ca4e3529a7e94) (thanks @extremeheat)
* [Update and rename CONTRIBUTING.md to docs/CONTRIBUTING.md (#475)](https://github.com/PrismarineJS/bedrock-protocol/commit/be6f0cde9f7970a4f9aa376c589c58d8cb4187c3) (thanks @extremeheat)
* [Add flow and deviceType options to relay (#464)](https://github.com/PrismarineJS/bedrock-protocol/commit/842e66266f09e8670a644a618d0ac4157746cd43) (thanks @GameParrot)
## 3.33.1
* [Fix zigzag type move in prismarine-nbt (#471)](https://github.com/PrismarineJS/bedrock-protocol/commit/7b74cbf7129646adc80d50304afce6240848cfae) (thanks @extremeheat)
## 3.33.0
* [1.20.50 (#466)](https://github.com/PrismarineJS/bedrock-protocol/commit/d53211c6a1fe5f941ce547886ad6ec031ae05d9d) (thanks @extremeheat)
* [Add 1.20.30 and 1.20.40 to index.d.ts (#461)](https://github.com/PrismarineJS/bedrock-protocol/commit/2ecf01d63e64b910b87f303fc4fb2b30f392cb28) (thanks @CreeperG16)
## 3.32.0
* [1.20.40 support (#459)](https://github.com/PrismarineJS/bedrock-protocol/commit/63eb673c1f30beb58f97e3b37295129000bf6a10) (thanks @CreeperG16)
* [Update Minecraft wiki link to new domain (#455)](https://github.com/PrismarineJS/bedrock-protocol/commit/689658c4ab1ccb3ef1ae812d78d090212b1acf3f) (thanks @Spongecade)
## 3.31.0
* [1.20.30](https://github.com/PrismarineJS/bedrock-protocol/commit/22502b90fdc29f6327239c6c201370c8f839c892) (thanks @extremeheat)
* [Add links field to server resource_packs_info](https://github.com/PrismarineJS/bedrock-protocol/commit/f92db61c89851dfbdbc906f926fc1433162854d0) (thanks @extremeheat)
* [Update API.md (#448)](https://github.com/PrismarineJS/bedrock-protocol/commit/8f3b6c5aecf24d6f8d235afe2a9d911840e6a3f8) (thanks @Laamy)
## 3.30.1
* [Update Mojang public key used for logins (#443)](https://github.com/PrismarineJS/bedrock-protocol/commit/f0f1351d40966192e38ee9fe21b7c37754abba04) (thanks @GameParrot)
* [index.d.ts: Fixed a typo (#441)](https://github.com/PrismarineJS/bedrock-protocol/commit/2c00402a9e9a0a283e712bf4f52190a57ea12c3f) (thanks @kotinash)
* [Mark `listen` and `close` as async (#440)](https://github.com/PrismarineJS/bedrock-protocol/commit/50cd489f6e16fa6fe04b1825617d8246bd3935f4) (thanks @MrSterdy)
* [Stop disconnecting when upstream packet deserialization fails (#435)](https://github.com/PrismarineJS/bedrock-protocol/commit/141442057464b3247ace8468863f27a3c334306e) (thanks @MrSterdy)
* [Add 1.20.0 and 1.20.10 to index.d.ts (#431)](https://github.com/PrismarineJS/bedrock-protocol/commit/010d57e78a9130c612e48db7a32f841de83e9c68) (thanks @CreeperG16)
## 3.30.0
* 1.20.10 support (thanks @CreeperG16)
* [Fix upstream relay batchingInterval (#425)](https://github.com/PrismarineJS/bedrock-protocol/commit/b2c141c25f3fad9641644742b6cc1a71bc601d61) (thanks @GameParrot)
## 3.29.1
* Add missing data to client login user chain (#420)
* Add FAQ entry and replit warning on client ping error (#415)
* Types: Fix Relay authTitle type (#418)
## 3.29.0
* 1.20.0 support
## 3.28.1
* Fix `followPort` option (@LucienHH)
* Typescript definition fixes (@hvlxh)
## 3.28.0
* 1.19.80 support
## 3.27.1
* Fix `raknetBackend` option not being applied correctly
## 3.27.0
* Corrections to types (@stevarino)
* Expose ServerAdvertisement class (#368) @hvlxh
* Update mc-data links
## 3.26.0
* 1.19.70 support (@CreeperG16)
* types: add some type hints (#354) @hvlxh
## 3.25.0
* 1.19.63 support (@stevarino)
* Add close packet in server player API doc (#347) @hvlxh
## 3.24.0
* 1.19.62 support (@CreeperG16)
## 3.23.0
* 1.19.60 support (@CreeperG16)
* added onMsaCode, profilesFolder to ClientOptions (@jarco-dev)
## 3.22.0
* 1.19.50 support (@WillQizza)
## 3.21.0
* 1.19.40 support (#314)
* types: Fix missing field in ServerAdvertisement (#313) (@minerj101)
## 3.20.1
* Fix buffer length calculation in ServerAdvertisement (#292) (thanks @KurtThiemann)
* Handle Relay serialization errors by kicking (#290)
## 3.20.0
* Preliminary 1.19.30 support, improve error handling and server pong data (#284)
## 3.19.0
* Add option for port redirection, fix Realm handling (#282)
* Add Port Redirect Functionality (#278) @stevarino
* Add Get-AppxPackage command to FAQ.md (#276) @stevarino
* Remove viewer example
## 3.18.0
* 1.19.21 support (#266)
## 3.17.0
* relay: Add multi-user login support (#258)
* Add fields from 1.19.20 to login chain data (#259) @CleSucre
* Fix nbt encoding size on single null tag NBT (#264)
* test: Add -u flag unzipping vanilla server (#262)
## 3.16.0
* 1.19.20 support (#251)
* Add new raknet library option (raknet-node) (#211) @b23r0
## 3.15.0
* 1.19.10 support
* Remove Realm fetch when joining via invite (#228) @LucienHH
* Add Realm support to Relay (#226) @ATXLtheAxolotl
## 3.14.0
* 1.19 support
* Better handle ping timeout, update documentation (#218) @stevarino
## 3.13.0
* Update API documentation
* Emit generic 'packet' event for server clients (#205) @ATXLtheAxolotl
* Add XUID field for client offline mode client chain (#203)
## 3.12.0
* 1.18.30 support
## 3.11.1
* Bump minecraft-data version
## 3.11.0
* Implement Realm joining (#193) @LucienHH
* Refactor client connection sequence (#189) @extremeheat
* Add profilesFolder to Relay (#192) @CreeperG16
* Emit error from relay when server can't be pinged (#191)
* Pass relay onMsaCode to client (#190) @Heath123
* Mark raknet-native as required dependency (#188)
* Ignore unconnected packets, remove babel (#185)
## 3.10.0
* Support 1.18.11 (#179) @extremeheat
* Switch to sync zlib with 512k chunks, adjustable compression level (#174) @extremeheat
## 3.9.0
* Proxy fixes, logging and doc updates [#169](https://github.com/PrismarineJS/bedrock-protocol/pull/169)
## 3.8.0
* 1.18.0 support
## 3.7.0
* 1.17.40 support
## 3.6.0
* 1.17.30 support
* minecraft-data used for protocol data
## 3.5.1
* Fix 1.17.10 npc packet serialization (#119)
## 3.5.0
* Add 1.17.10 support [#109](https://github.com/PrismarineJS/bedrock-protocol/pull/109)
* You can switch to the JS implementation of raknet by setting `useNativeRaknet: false` in options.
## 3.4.0
* Initial 1.17 support [#99](https://github.com/PrismarineJS/bedrock-protocol/pull/99)
* update connect version based on ping response & fix typings (u9g) [#101](https://github.com/PrismarineJS/bedrock-protocol/pull/101)
* fix: ping types. (JammSpread) [#100](https://github.com/PrismarineJS/bedrock-protocol/pull/100)
## 3.3.0
* Protocol updates for 1.16, with some minor breaking changes to protocol fields [#95](https://github.com/PrismarineJS/bedrock-protocol/pull/95)
* Fix npm install issues
## 3.2.1
* Add `authTitle` option to Relay proxy [#92](https://github.com/PrismarineJS/bedrock-protocol/pull/92)
* Protocol, type definition fixes
## 3.2.0 ## 3.2.0
* Fix empty chunks on proxy spawn [#89](https://github.com/PrismarineJS/bedrock-protocol/pull/89) * Fix empty chunks on proxy spawn (#89)
* Send skin data to server [#88](https://github.com/PrismarineJS/bedrock-protocol/pull/88) * Send skin data to server (#88)
* Support xbox title + live.com auth [#86](https://github.com/PrismarineJS/bedrock-protocol/pull/86) * Support xbox title + live.com auth (#86)
* Protocol updates and fixes * Protocol updates and fixes
* Fix third party servers, optional client encryption [#83](https://github.com/PrismarineJS/bedrock-protocol/pull/83) * Fix third party servers, optional client encryption (#83)
## 3.1.0 ## 3.1.0
* Add support for 1.16 * Add support for 1.16

View file

@ -1,20 +1,20 @@
# bedrock-protocol # bedrock-protocol
[![NPM version](https://img.shields.io/npm/v/bedrock-protocol.svg)](http://npmjs.com/package/bedrock-protocol) [![NPM version](https://img.shields.io/npm/v/bedrock-protocol.svg)](http://npmjs.com/package/bedrock-protocol)
[![Build Status](https://github.com/PrismarineJS/bedrock-protocol/workflows/CI/badge.svg)](https://github.com/PrismarineJS/bedrock-protocol/actions?query=workflow%3A%22CI%22) [![Build Status](https://github.com/PrismarineJS/bedrock-protocol/workflows/CI/badge.svg)](https://github.com/PrismarineJS/bedrock-protocol/actions?query=workflow%3A%22CI%22)
[![Discord](https://img.shields.io/badge/chat-on%20discord-brightgreen.svg)](https://discord.gg/GsEFRM8)
[![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/bedrock-protocol) [![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/bedrock-protocol)
[![Official Discord](https://img.shields.io/static/v1.svg?label=OFFICIAL&message=DISCORD&color=blue&logo=discord&style=for-the-badge)](https://discord.gg/GsEFRM8)
Minecraft Bedrock Edition (aka MCPE) protocol library, supporting authentication and encryption. Help [contribute](docs/CONTRIBUTING.md). Minecraft Bedrock Edition (aka MCPE) protocol library, supporting authentication and encryption. Help [contribute](CONTRIBUTING.md).
[Protocol doc](https://prismarinejs.github.io/minecraft-data/?v=bedrock_1.19.10&d=protocol) This is a work in progress. You can track the progress in https://github.com/PrismarineJS/bedrock-protocol/pull/34.
## Features ## Features
- Supports Minecraft Bedrock version 1.16.201, 1.16.210, 1.16.220, 1.17.0, 1.17.10, 1.17.30, 1.17.40, 1.18.0, 1.18.11, 1.18.30, 1.19.1, 1.19.10, 1.19.20, 1.19.21, 1.19.30, 1.19.40, 1.19.41, 1.19.50, 1.19.60, 1.19.62, 1.19.63, 1.19.70, 1.19.80, 1.20.0, 1.20.10, 1.20.30, 1.20.40, 1.20.50, 1.20.61, 1.20.71, 1.20.80, 1.21.0, 1.21.2, 1.21.21, 1.21.30, 1.21.42, 1.21.50, 1.21.60, 1.21.70, 1.21.80, 1.21.90, 1.21.93, 1.21.100, 1.21.111 - Supports Minecraft Bedrock version 1.16.201, 1.16.210, 1.16.220
- Parse and serialize packets as JavaScript objects - Parse and serialize packets as JavaScript objects
- Automatically respond to keep-alive packets - Automatically respond to keep-alive packets
- [Proxy and mitm connections](docs/API.md#proxy-docs) - [Proxy and mitm connections](docs/API.md)
- Client - Client
- Authentication - Authentication
- Encryption - Encryption
@ -34,55 +34,40 @@ Want to contribute on something important for PrismarineJS ? go to https://githu
`npm install bedrock-protocol` `npm install bedrock-protocol`
To update bedrock-protocol (or any Node.js package) and its dependencies after a previous install, you must run `npm update --depth 9999`
## Usage ## Usage
### Client example ### Client example
Example to connect to a server in offline mode, and relay chat messages back:
```js ```js
const bedrock = require('bedrock-protocol') const bedrock = require('bedrock-protocol')
const client = bedrock.createClient({ const client = bedrock.createClient({
host: 'localhost', // optional host: 'localhost', // optional
port: 19132, // optional, default 19132 port: 19132, // optional, default 19132
username: 'Notch', // the username you want to join as, optional if online mode username: 'Notch', // the username you want to join as, optional if online mode
offline: true // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. offline: true // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true.
// Optional for some servers which verify the title ID:
// authTitle: bedrock.title.MinecraftNintendoSwitch
}) })
client.on('text', (packet) => { // Listen for chat messages from the server and echo them back. client.on('text', (packet) => { // Listen for chat messages and echo them back.
if (packet.source_name != client.username) { if (packet.source_name != client.options.username) {
client.queue('text', { client.queue('text', {
type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '', filtered_message: '', type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '',
message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}` message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}`
}) })
} }
}) })
``` ```
### Client example joining a Realm
Example to connect to a Realm that the authenticating account is owner of or has been invited to:
```js
const bedrock = require('bedrock-protocol')
const client = bedrock.createClient({
realms: {
pickRealm: (realms) => realms[0] // Function which recieves an array of joined/owned Realms and must return a single Realm. Can be async
}
})
```
### Server example ### Server example
*Can't connect locally on Windows? See the [faq](docs/FAQ.md)* *Can't connect locally on Windows? See the [faq](docs/FAQ.md)*
```js ```js
const bedrock = require('bedrock-protocol') const bedrock = require('bedrock-protocol')
const server = bedrock.createServer({ const server = new bedrock.createServer({
host: '0.0.0.0', // optional. host to bind as. host: '0.0.0.0', // optional. host to bind as.
port: 19132, // optional port: 19132, // optional
version: '1.17.10', // optional. The server version, latest if not specified. version: '1.16.220', // optional. The server version, latest if not specified.
}) })
server.on('connect', client => { server.on('connect', client => {
@ -104,11 +89,11 @@ ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => {
## Documentation ## Documentation
For documentation on the protocol, and packets/fields see the [protocol documentation](https://prismarinejs.github.io/minecraft-data/protocol). For documentation on the protocol, and packets/fields see the [proto.yml](data/latest/proto.yml) and [types.yml](data/latest/proto.yml) files.
* See [API documentation](docs/API.md) See [API documentation](docs/API.md)
* See [frequently asked questions and answers](docs/FAQ.md) See [faq](docs/FAQ.md)
<!-- ## Projects Using bedrock-protocol <!-- ## Projects Using bedrock-protocol
@ -128,7 +113,7 @@ Through node.js, add `process.env.DEBUG = 'minecraft-protocol'` at the top of yo
## Contribute ## Contribute
Please read [CONTRIBUTING.md](docs/CONTRIBUTING.md) and https://github.com/PrismarineJS/prismarine-contribute Please read [CONTRIBUTING.md](CONTRIBUTING.md) and https://github.com/PrismarineJS/prismarine-contribute
## History ## History

3
babel.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
presets: ['@babel/preset-env']
}

7154
data/1.16.201/protocol.json generated Normal file

File diff suppressed because it is too large Load diff

120
data/1.16.201/steve.json generated Normal file

File diff suppressed because one or more lines are too long

5147
data/1.16.201/steveGeometry.json generated Normal file

File diff suppressed because it is too large Load diff

BIN
data/1.16.201/steveSkin.bin Normal file

Binary file not shown.

7781
data/1.16.210/protocol.json generated Normal file

File diff suppressed because it is too large Load diff

8542
data/1.16.220/protocol.json generated Normal file

File diff suppressed because it is too large Load diff

2647
data/latest/proto.yml Normal file

File diff suppressed because it is too large Load diff

1618
data/latest/types.yaml Normal file

File diff suppressed because it is too large Load diff

44
data/provider.js Normal file
View file

@ -0,0 +1,44 @@
const { Versions } = require('../src/options')
const { getFiles } = require('../src/datatypes/util')
const { join } = require('path')
let fileMap = {}
// Walks all the directories for each of the supported versions in options.js
// then builds a file map for each version
// { 'protocol.json': { '1.16.200': '1.16.200/protocol.json', '1.16.210': '1.16.210/...' } }
function loadVersions () {
for (const version in Versions) {
let files = []
try {
files = getFiles(join(__dirname, '/', version))
} catch {}
for (const file of files) {
const rfile = file.replace(join(__dirname, '/', version) + '/', '')
fileMap[rfile] = fileMap[rfile] ?? []
fileMap[rfile].push([Versions[version], file])
fileMap[rfile].sort().reverse()
}
}
}
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
getPath (file) {
if (!fileMap[file]) {
throw Error('Unknown file ' + file)
}
for (const [pver, path] of fileMap[file]) {
if (pver <= protocolVersion) {
// console.debug('for', file, 'returining', path)
return path
}
}
throw Error('unknown file ' + file)
}
}
}

View file

@ -8,39 +8,17 @@ Returns a `Client` instance and connects to the server.
| Parameter | Optionality | Description | | Parameter | Optionality | Description |
| ----------- | ----------- |-| | ----------- | ----------- |-|
| host | Conditional | Not required if `realms` is set. host to connect to, for example `127.0.0.1`. | | host | **Required** | host to connect to, for example `127.0.0.1`. |
| port | *optional* | port to connect to, default to **19132** | | port | *optional* | port to connect to, default to **19132** |
| version | *optional* | Version to connect as. If not specified, automatically match server version. | | version | *optional* | Version to connect as. <br/>(Future feature, see [#69][1]) If not specified, should automatically match server version. <br/>(Current feature) Defaults to latest version. |
| offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth. | | offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth. |
| username | Required | The profile name to connect to the server as. If `offline` set to true, the username that will appear on join, that would normally be the Xbox Gamer Tag. | | username | Conditional | Required if `offline` set to true : Username to connect to server as. |
| authTitle | *optional* | The title ID to connect as, see the README for usage. |
| connectTimeout | *optional* | default to **9000ms**. How long to wait in milliseconds while trying to connect to server. | | connectTimeout | *optional* | default to **9000ms**. How long to wait in milliseconds while trying to connect to server. |
| onMsaCode | *optional* | Callback called when signing in with a microsoft account with device code auth, `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) | | onMsaCode | *optional* | Callback called when signing in with a microsoft account with device code auth, `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) |
| profilesFolder | *optional* | Where to store cached authentication tokens. Defaults to .minecraft, or the node_modules folder if not found. | | profilesFolder | *optional* | Where to store cached authentication tokens. Defaults to .minecraft, or the node_modules folder if not found. |
| skipPing | *optional* | Whether pinging the server to check its version should be skipped. | | autoInitPlayer | optional | default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn. |
| followPort | *optional* | Update the options' port parameter to match the port broadcast on the server's ping data (default to true if `realms` not specified) |
| autoInitPlayer | *optional* | default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn. |
| conLog | *optional* | Where to log connection information (server join, kick messages to). Defaults to console.log, set to `null` to not log anywhere. |
| raknetBackend | *optional* | Specifies the raknet implementation to use. Possible options are 'raknet-native' (default, original C++ implementation), 'jsp-raknet' (JS port), and 'raknet-node' (Rust port). Please note when using the non-JS implementation you may the need approporate build tools on your system (for example a C++ or Rust compiler). |
| compressionLevel | *optional* | What zlib compression level to use, default to **7** |
| batchingInterval | *optional* | How frequently, in milliseconds to flush and write the packet queue (default: 20ms) |
| realms | *optional* | An object which should contain one of the following properties: `realmId`, `realmInvite`, `pickRealm`. When defined will attempt to join a Realm without needing to specify host/port. **The authenticated account must either own the Realm or have been invited to it** |
| realms.realmId | *optional* | The id of the Realm to join. |
| realms.realmInvite | *optional* | The invite link/code of the Realm to join. |
| realms.pickRealm | *optional* | A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm. |
*`useNativeRaknet` is deprecated. Setting to true will use 'raknet-native' for `raknetBackend` and setting it to false will use a JavaScript implemenation (jsp-raknet)*
The following special events are emitted by the client on top of protocol packets:
* 'status' - When the client's login sequence status has changed
* 'join' - When the client has joined the server after authenticating
* 'spawn' - When the client has spawned into the game world, as it is getting chunks
* 'kick' - The server has kicked the client
* 'close' - The server has closed the connection
* 'error' - An recoverable exception has happened. Not catching will throw an exception
* 'connect_allowed' - Emitted after the client has pinged the server and gets version information.
* 'heartbeat' - Emitted after two successful tick_sync (keepalive) packets have been sent bidirectionally
* 'packet' - Emitted for all packets received by client
* 'session' - When the client has finished authenticating and connecting
## be.createServer(options) : Server ## be.createServer(options) : Server
@ -58,16 +36,20 @@ authenticated unless offline is set to true.
| maxPlayers | *optional* | default to **3**. Set this to change the maximum number of players connected. | | maxPlayers | *optional* | default to **3**. Set this to change the maximum number of players connected. |
| kickTimeout | *[Future][1]* | How long to wait before kicking a unresponsive client. | | kickTimeout | *[Future][1]* | How long to wait before kicking a unresponsive client. |
| motd | *optional* | The "message of the day" for the server, the message shown to players in the server list. See usage below. | | motd | *optional* | The "message of the day" for the server, the message shown to players in the server list. See usage below. |
| advertisementFn | *optional* | optional. Custom function to call that should return a ServerAdvertisement, used for setting the RakNet server PONG data. Overrides `motd`. | | advertismentFn | *optional* | optional. Custom function to call that should return a ServerAdvertisement, used for setting the RakNet server PONG data. Overrides `motd`. |
| conLog | *optional* | Where to log connection information (server join, kick messages to). Default to log only in DEBUG mode. |
| raknetBackend | *optional* | Specifies the raknet implementation to use. Possible options are 'raknet-native' (default, original C++ implementation), 'jsp-raknet' (JS port), and 'raknet-node' (Rust port). Please note when using the non-JS implementation you may the need approporate build tools on your system (for example a C++ or Rust compiler). |
*`useNativeRaknet` is deprecated. Setting to true will use 'raknet-native' for `raknetBackend` and setting it to false will use a JavaScript implemenation (jsp-raknet)*
## be.ping({ host, port }) : ServerAdvertisement ## be.ping({ host, port }) : ServerAdvertisement
Ping a server and get the response. See type definitions for the structure. Ping a server and get the response. See type definitions for the structure.
## Methods
[See the type defintions for this library for more information on methods.](../index.d.ts)
Both Client and Server classes have `write(name, params)` and `queue(name, params)` methods. The former sends a packet immediately, and the latter queues them to be sent in the next packet batch. Prefer the latter for better performance and less blocking.
You can use `.close()` to terminate a connection, and `.disconnect(reason)` to gracefully kick a connected client.
## Server usage ## Server usage
You can create a server as such: You can create a server as such:
@ -78,7 +60,7 @@ const server = bedrock.createServer({
port: 19132, // optional, port to bind to, default 19132 port: 19132, // optional, port to bind to, default 19132
offline: false, // default false. verify connections with XBL offline: false, // default false. verify connections with XBL
motd: { motd: {
motd: 'Funtime Server', // Top level message shown in server list name: 'Funtime Server', // Top level message shown in server list
levelName: 'Wonderland' // Sub-level header levelName: 'Wonderland' // Sub-level header
} }
}) })
@ -87,34 +69,25 @@ const server = bedrock.createServer({
Then you can listen for clients and their events: Then you can listen for clients and their events:
```js ```js
// The 'connect' event is emitted after a new client has started a connection with the server and is handshaking. // The 'connect' event is emitted after a new client has started a connection with the server and is handshaking.
// Its one paramater is the ServerPlayer class instance which handles this players' session from here on out. // Its one paramater is the client class instance which handles this session from here on out.
server.on('connect', (client) => { server.on('connect', (client) => {
// 'join' is emitted after the client has authenticated & connection is now encrypted. // 'join' is emitted after the client has authenticated & connection is now encrypted.
client.on('join', () => { client.on('join', () => {
// Then we can continue with the server spawning sequence. See examples/serverTest.js for an example spawn sequence. // Then we can continue with the server spawning sequence. See examples/serverTest.js for an example spawn sequence.
// ...
// Here's an example of sending a "text" packet, https://prismarinejs.github.io/minecraft-data/?v=bedrock_1.19.60&d=protocol#packet_text
client.queue('text', { type: 'system', message: client.profile.name + ' just joined the server!' })
}) })
}) })
``` ```
Server event emissions: Order of server client event emissions:
* 'connect' - emitted by `Server` after a client first joins the server. Second paramater is a `ServerPlayer` instance. * 'connect' - emitted by `Server` after a client first joins the server. Second paramater is a `ServerPlayer` instance.
'error' event is emitted when a catchable exception happens with a client (for example receiving a bad encrypted packet).
A ServerPlayer instance also emits the following special events:
* 'join' - the client is ready to recieve game packets after successful server-client handshake/encryption
* 'close' - emitted when client quit the server
* 'login' - emitted by client after the client has been authenticated by the server * 'login' - emitted by client after the client has been authenticated by the server
* 'join' - the client is ready to recieve game packets after successful server-client handshake/encryption
* 'spawn' - emitted after the client lets the server know that it has successfully spawned * 'spawn' - emitted after the client lets the server know that it has successfully spawned
* 'packet' - Emitted for all packets received by client
## Client usage ## Client docs
You can create a client like below: You can create a server as such:
```js ```js
const bedrock = require('bedrock-protocol') const bedrock = require('bedrock-protocol')
const client = bedrock.createClient({ const client = bedrock.createClient({
@ -130,22 +103,12 @@ const client = bedrock.createClient({
client.on('join', client => console.log('Player has joined!')) client.on('join', client => console.log('Player has joined!'))
// The 'spawn' event is emitted. The chunks have been sent and all is well. // The 'spawn' event is emitted. The chunks have been sent and all is well.
client.on('spawn', client => console.log('Player has spawned!')) client.on('join', client => console.log('Player has spawned!'))
// We can listen for text packets. See proto.yml for documentation. // We can listen for text packets. See proto.yml for documentation.
client.on('text', (packet) => { client.on('text', (packet) => {
console.log('Client got text packet', packet) console.log('Client got text packet', packet)
}) })
// For example, we can listen to https://prismarinejs.github.io/minecraft-data/?v=bedrock_1.19.60&d=protocol#packet_add_player
// and send them a chat message when a player joins saying hello. Note the lack of the `packet` prefix, and that the packet
// names and as explained in the "Protocol doc" section below, fields are all case sensitive!
client.on('add_player', (packet) => {
client.queue('text', {
type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '', filtered_message: '',
message: `Hey, ${packet.username} just joined!`
})
})
``` ```
Order of client event emissions: Order of client event emissions:
@ -154,38 +117,14 @@ Order of client event emissions:
* 'join' - the client is ready to recieve game packets after successful server-client handshake * 'join' - the client is ready to recieve game packets after successful server-client handshake
* 'spawn' - emitted after the client has permission from the server to spawn * 'spawn' - emitted after the client has permission from the server to spawn
## Methods
[See the type defintions for this library for more information on methods.](../index.d.ts)
Both Client and ServerPlayer classes have `write(name, params)` and `queue(name, params)` methods. The former sends a packet immediately, and the latter queues them to be sent in the next packet batch. Prefer the latter for better performance and less blocking.
You can use `.close()` to terminate a connection, and `.disconnect(reason)` to gracefully kick a connected client.
### Protocol docs ### Protocol docs
For documentation on the protocol, and packets/fields see the [the protocol doc](https://prismarinejs.github.io/minecraft-data/?v=bedrock_1.18.0&d=protocol) (the emitted event names are the Packet types in lower case without the "packet_" prefix). More information on syntax can be found in CONTRIBUTING.md. When sending a packet, you must fill out all of the required fields. For documentation on the protocol, and packets/fields see the [proto.yml](data/latest/proto.yml) and [types.yml](data/latest/proto.yml) files. More information on syntax can be found in CONTRIBUTING.md. When sending a packet, you must fill out all of the required fields.
### Realm docs
To make joining a Realm easier we've added an optional `realm` property to the client. It accepts the following options `realmId`, `realmInvite`, and `pickRealm`, supplying one of these will fetch host/port information for the specified Realm and then attempt to connect the bot.
- `realmId` - The id of the Realm to join.
- `realmInvite` - The invite code/link of the Realm to join.
- `pickRealm` - A function that will be called with a list of Realms to pick from. The function should return the Realm to join.
```js
const bedrock = require('bedrock-protocol')
const client = bedrock.createClient({
realms: {
pickRealm: (realms) => realms[0] // Function which recieves an array of joined/owned Realms and must return a single Realm. Can be async
}
})
```
### Proxy docs ### Proxy docs
You can create a proxy ("Relay") to create a machine-in-the-middle (MITM) connection to a server. You can observe and intercept packets as they go through. The Relay is a server+client combo with some special packet handling and forwarding that takes care of the authentication and encryption on the server side. Clients will be asked to login if `offline` is not specified on connection. You can create a proxy ("Relay") to create a machine-in-the-middle (MITM) connection to a server. You can observe and intercept packets as they go through. The Relay is a server+client combo with some special packet handling and forwarding that takes care of the authentication and encryption on the server side. You'll be asked to login if `offline` is not specified once you connect.
```js ```js
const { Relay } = require('bedrock-protocol') const { Relay } = require('bedrock-protocol')
@ -206,24 +145,20 @@ relay.on('connect', player => {
console.log('New connection', player.connection.address) console.log('New connection', player.connection.address)
// Server is sending a message to the client. // Server is sending a message to the client.
player.on('clientbound', ({ name, params }, des) => { player.on('clientbound', ({ name, params }) => {
if (name === 'disconnect') { // Intercept kick if (name === 'disconnect') { // Intercept kick
params.message = 'Intercepted' // Change kick message to "Intercepted" params.message = 'Intercepted' // Change kick message to "Intercepted"
} }
}) })
// Client is sending a message to the server // Client is sending a message to the server
player.on('serverbound', ({ name, params }, des) => { player.on('serverbound', ({ name, params }) => {
if (name === 'text') { // Intercept chat message to server and append time. if (name === 'text') { // Intercept chat message to server and append time.
params.message += `, on ${new Date().toLocaleString()}` params.message += `, on ${new Date().toLocaleString()}`
} }
if (name === 'command_request') { // Intercept command request to server and cancel if its "/test"
if (params.command == "/test") {
des.canceled = true
}
}
}) })
}) })
``` ```
'Relay' emits 'clientbound' and 'serverbound' events, along with the data for the outgoing packet that can be modified. You can send a packet to the client with `player.queue()` or to the backend server with `player.upstream.queue()`. 'Relay' emits 'clientbound' and 'serverbound' events, along with the data for the outgoing packet that can be modified. You can send a packet to the client with `player.queue()` or to the backend server with `player.upstream.queue()`.
[1]: https://github.com/PrismarineJS/bedrock-protocol/issues/69

View file

@ -5,38 +5,6 @@ This issue occurs due to loopback restrictions on Windows 10 UWP apps. To lift t
```ps ```ps
CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe" CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe"
``` ```
If you are running a preview or beta release, you can run the following command to unlock that version:
```ps
CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftWindowsBeta"
```
If that still doesn't work, you can inspect what Minecraft versions are available on your system with:
```ps
Get-AppxPackage -AllUsers | Where Name -Match ".*Minecraft.*" | Select Name,InstallLocation,PackageFullName
```
Use the PackageFullName field in place of the `Microsoft.MinecraftUWP_8wekyb3d8bbwe` for the command above.
## Replit
Replit may [not support](https://github.com/PrismarineJS/bedrock-protocol/issues/363) the necessary outbound UDP connections required to connect to a Minecraft server. For further assistance using Replit, please contact Replit support or consider using an alternative hosting service if hosting locally is not possible.
Some alternatives:
* [Gitpod](https://www.gitpod.io/)
* Gitpod is a cloud development environment for teams to efficiently and securely develop software, right from your browser.
* [Github Codespaces](https://github.com/features/codespaces)
* A Codespace is a developer environment like Gitpod that's hosted in the cloud, accessed in your browser.
* [Google Colab](https://colab.research.google.com/)
* Google Colab is a Jupyter notebook environment. Jupyter notebook offer a Python environment where you can write, explain, visualize and execute code straight from a web-based developer environment. For more information on using Colab for JavaScript projects, see [Mineflayer on Google Colab](https://colab.research.google.com/github/PrismarineJS/mineflayer/blob/master/docs/mineflayer.ipynb).
## Kicked during login ## Kicked during login
NOTE: If you not receiving any errors, the error probably logged in debug mode which is not enabled. To enable it, set `process.env.DEBUG = 'minecraft-protocol'` to the top of the file
Some servers can kick you if you don't set `authTitle` as explained in the README. Some servers can kick you if you don't set `authTitle` as explained in the README.
## Server clients kicked due to "jwt not active"
The system time is incorrect and needs to be corrected.

View file

@ -1,13 +0,0 @@
/* eslint-disable */
const bedrock = require('bedrock-protocol')
const client = bedrock.createClient({
realms: {
// realmId: '1234567', // Connect the client to a Realm using the Realms ID
// realmInvite: 'https://realms.gg/AB1CD2EFA3B', // Connect the client to a Realm using the Realms invite URL or code
pickRealm: (realms) => realms.find(e => e.name === 'Realm Name') // Connect the client to a Realm using a function that returns a Realm
}
})
client.on('text', (packet) => { // Listen for chat messages
console.log('Received Text:', packet)
})

View file

@ -4,14 +4,16 @@ const client = bedrock.createClient({
host: 'localhost', // optional host: 'localhost', // optional
port: 19132, // optional, default 19132 port: 19132, // optional, default 19132
username: 'Notch', // the username you want to join as, optional if online mode username: 'Notch', // the username you want to join as, optional if online mode
offline: false // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. offline: false, // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true.
// Optional for some servers which verify the title ID:
// authTitle: bedrock.title.MinecraftNintendoSwitch
}) })
client.on('text', (packet) => { // Listen for chat messages and echo them back. client.on('text', (packet) => { // Listen for chat messages and echo them back.
if (packet.source_name != client.username) { if (packet.source_name != client.options.username) {
client.queue('text', { client.queue('text', {
type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '', filtered_message: '', type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '',
message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}` message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}`
}) })
} }
}) })

View file

@ -1,9 +1,6 @@
/**
* Do not use this example unless you need to change the login procedure, instead see `client.js`.
*/
process.env.DEBUG = 'minecraft-protocol raknet' process.env.DEBUG = 'minecraft-protocol raknet'
const { Client } = require('bedrock-protocol') const { Client } = require('bedrock-protocol')
const ChunkColumn = require('bedrock-provider').chunk('bedrock_1.17.10') const { ChunkColumn, Version } = require('bedrock-provider')
async function test () { async function test () {
const client = new Client({ const client = new Client({
@ -33,7 +30,7 @@ async function test () {
}) })
client.on('level_chunk', async packet => { client.on('level_chunk', async packet => {
const cc = new ChunkColumn(packet.x, packet.z) const cc = new ChunkColumn(Version.v1_4_0, packet.x, packet.z)
await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count) await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count)
const blocks = [] const blocks = []
for (let x = 0; x < 16; x++) { for (let x = 0; x < 16; x++) {

View file

@ -1,4 +1,4 @@
const { Relay } = require('bedrock-protocol') const { Relay } = require('../src/relay')
function createRelay () { function createRelay () {
console.log('Creating relay') console.log('Creating relay')
@ -7,12 +7,10 @@ function createRelay () {
/* host and port for clients to listen to */ /* host and port for clients to listen to */
host: '0.0.0.0', host: '0.0.0.0',
port: 19130, port: 19130,
offline: false,
/* Where to send upstream packets to */ /* Where to send upstream packets to */
destination: { destination: {
host: '127.0.0.1', host: '127.0.0.1',
port: 19132, port: 19132
offline: false
} }
}) })
relay.conLog = console.debug relay.conLog = console.debug

View file

@ -1,29 +0,0 @@
const { Relay } = require('bedrock-protocol')
function createRelay () {
console.log('Creating relay')
/* Example to create a non-transparent proxy (or 'Relay') connection to destination server */
const relay = new Relay({
/* host and port for clients to listen to */
host: '0.0.0.0',
port: 19130,
offline: false,
/* Where to send upstream packets to */
destination: {
realms: {
pickRealm: (realms) => realms.find(e => e.name === 'Realm Name')
},
offline: false
}
})
relay.conLog = console.debug
relay.listen()
relay.on('connect', player => {
// Server is sending a message to the client.
player.on('clientbound', ({ name, params }) => {
if (name === 'text') console.log(params)
})
})
}
createRelay()

View file

@ -4,7 +4,7 @@ const { LevelDB } = require('leveldb-zlib')
const { join } = require('path') const { join } = require('path')
async function loadWorld (version) { async function loadWorld (version) {
const path = join(__dirname, `../../tools/bds-${version}/worlds/Bedrock level/db`) const path = join(__dirname, `../tools/bds-${version}/worlds/Bedrock level/db`)
console.log('Loading world at path', path) // Load world from testing server console.log('Loading world at path', path) // Load world from testing server
const db = new LevelDB(path, { createIfMissing: false }) const db = new LevelDB(path, { createIfMissing: false })
await db.open() await db.open()
@ -19,8 +19,11 @@ async function loadWorld (version) {
for (let cx = cxStart; cx < cxEnd; cx++) { for (let cx = cxStart; cx < cxEnd; cx++) {
for (let cz = czStart; cz < czEnd; cz++) { for (let cz = czStart; cz < czEnd; cz++) {
// console.log('reading chunk at ', cx, cz)
const cc = await wp.load(cx, cz, true) const cc = await wp.load(cx, cz, true)
if (!cc) { if (!cc) {
// console.log('no chunk')
continue continue
} }
const cbuf = await cc.networkEncodeNoCache() const cbuf = await cc.networkEncodeNoCache()
@ -32,6 +35,7 @@ async function loadWorld (version) {
blobs: [], blobs: [],
payload: cbuf payload: cbuf
}) })
// console.log('Ht',cc.sectionsLen,cc.sections)
} }
} }

View file

@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
const bedrock = require('bedrock-protocol') const bedrock = require('bedrock-protocol')
const server = bedrock.createServer({ const server = new bedrock.createServer({
host: '0.0.0.0', // optional host: '0.0.0.0', // optional
port: 19132, // optional port: 19132, // optional
version: '1.19.80', // The server version version: '1.16.220', // The server version
motd: { // The message of the day motd: { // The message of the day
motd: 'Funtime Server', motd: 'Funtime Server',
levelName: 'Wonderland' levelName: 'Wonderland'
@ -12,7 +12,7 @@ const server = bedrock.createServer({
server.on('connect', client => { server.on('connect', client => {
client.on('join', () => { // The client has joined the server. client.on('join', () => { // The client has joined the server.
const date = new Date() // Once client is in the server, send a colorful kick message const d = new Date() // Once client is in the server, send a colorful kick message
client.disconnect(`Good ${date.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'}\n\nMy time is ${date.toLocaleString()} !`) client.disconnect(`Good ${d.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'} :)\n\nMy time is ${d.toLocaleString()} !`)
}) })
}) })

View file

@ -1,36 +1,32 @@
/** /**
* This example spawns a client. For a basic server that disconnects users, see "basicServer.js".
*
* bedrock-protocol server example; to run this example you need to clone this repo from git. * 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 * first need to dump some packets from the vanilla server as there is alot of boilerplate
* to send to clients. The `serverChunks.js` contains the chunk loading code. * to send to clients.
* *
* In your server implementation, you need to implement each of the following packets to * 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` * 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`. * First, dump packets for version 1.16.210 by running `npm run dumpPackets`.
* Then you can run `node server.js <version>` to start this script.
*/ */
process.env.DEBUG = 'minecraft-protocol' // packet logging process.env.DEBUG = 'minecraft-protocol' // packet logging
// const fs = require('fs') // const fs = require('fs')
const { Server } = require('bedrock-protocol') const { Server } = require('../src/server')
const { hasDumps } = require('../tools/genPacketDumps')
const { hasDumps } = require('../../tools/genPacketDumps') const DataProvider = require('../data/provider')
const { waitFor } = require('../../src/datatypes/util') const { waitFor } = require('../src/datatypes/util')
const { loadWorld } = require('./serverChunks') const { loadWorld } = require('./serverChunks')
const { join } = require('path')
async function startServer (version = '1.17.10', ok) { async function startServer (version = '1.16.220', ok) {
if (!hasDumps(version)) { if (!hasDumps(version)) {
throw Error('You need to dump some packets first. Run tools/genPacketDumps.js') throw Error('You need to dump some packets first. Run tools/genPacketDumps.js')
} }
const Item = require('../../types/Item')(version) const Item = require('../types/Item')(version)
const port = 19132 const port = 19132
const server = new Server({ host: '0.0.0.0', port, version }) const server = new Server({ host: '0.0.0.0', port, version })
let loop let loop
const getPath = (packetPath) => join(__dirname, `../data/${server.options.version}/${packetPath}`) const getPath = (packetPath) => DataProvider(server.options.protocolVersion).getPath(packetPath)
const get = (packetName) => require(getPath(`sample/packets/${packetName}.json`)) const get = (packetName) => require(getPath(`sample/packets/${packetName}.json`))
server.listen() server.listen()
@ -53,8 +49,7 @@ async function startServer (version = '1.17.10', ok) {
must_accept: false, must_accept: false,
has_scripts: false, has_scripts: false,
behaviour_packs: [], behaviour_packs: [],
texture_packs: [], texture_packs: []
resource_pack_links: []
}) })
// ResourcePackStack is sent by the server to send the order in which resource packs and behaviour packs // ResourcePackStack is sent by the server to send the order in which resource packs and behaviour packs

View file

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

View file

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

View file

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

View file

@ -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({ host: '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 }

View file

@ -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({
host: '0.0.0.0',
port: 19130,
// logging: true,
destination: {
host: '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

View file

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

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>Prismarine Viewer</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id='splash'>
<div class='header'>Prismarine Viewer</div>
<div>
<div>Connecting to 127.0.0.1, port 19132...</div>
</div>
</div>
<script type="text/javascript" src="index.js"></script>
</body>
</html>

View file

@ -0,0 +1,4 @@
const { BotViewer } = require('./BotViewer')
global.viewer = new BotViewer()
global.viewer.start()

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
// hack for path resolving
require('prismarine-viewer/viewer/lib/worker')

44
examples/viewer/index.js Normal file
View file

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

View file

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

191
index.d.ts vendored
View file

@ -1,91 +1,49 @@
import EventEmitter from 'events' import EventEmitter from "events"
import { Realm } from 'prismarine-realms'
import { ServerDeviceCodeResponse } from 'prismarine-auth'
declare module 'bedrock-protocol' { declare module "bedrock-protocol" {
type Version = '1.21.93' | '1.21.90' | '1.21.80' | '1.21.70' | '1.21.60' | '1.21.50' | '1.21.42' | '1.21.30' | '1.21.2' | '1.21.0' | '1.20.80' | '1.20.71' | '1.20.61' | '1.20.50' | '1.20.40' | '1.20.30' | '1.20.10' | '1.20.0' | '1.19.80' | '1.19.70' | '1.19.63' | '1.19.62' | '1.19.60' | '1.19.51' | '1.19.50' | '1.19.41' | '1.19.40' | '1.19.31' | '1.19.30' | '1.19.22' | '1.19.21' | '1.19.20' | '1.19.11' | '1.19.10' | '1.19.2' | '1.19.1' | '1.18.31' | '1.18.30' | '1.18.12' | '1.18.11' | '1.18.10' | '1.18.2' | '1.18.1' | '1.18.0' | '1.17.41' | '1.17.40' | '1.17.34' | '1.17.30' | '1.17.11' | '1.17.10' | '1.17.0' | '1.16.220' | '1.16.210' | '1.16.201' type Version = '1.16.220' | '1.16.210' | '1.16.201'
enum title { MinecraftNintendoSwitch, MinecraftJava }
export interface Options { export interface Options {
// The string version to start the client or server as // The string version to start the client or server as
version?: Version version: number,
// For the client, the host of the server to connect to (default: 127.0.0.1) // For the client, the host of the server to connect to (default: 127.0.0.1)
// For the server, the host to bind to (default: 0.0.0.0) // For the server, the host to bind to (default: 0.0.0.0)
host: string host: string,
// The port to connect or bind to, default: 19132 // The port to connect or bind to, default: 19132
port: number port: number,
// For the client, if we should login with Microsoft/Xbox Live.
// For the server, if we should verify client's authentication with Xbox Live.
offline?: boolean
// Which raknet backend to use
raknetBackend?: 'jsp-raknet' | 'raknet-native' | 'raknet-node'
// If using JS implementation of RakNet, should we use workers? (This only affects the client)
useRaknetWorker?: boolean
// Compression level for zlib, default to 7
compressionLevel?: number
// How frequently the packet queue should be flushed in milliseconds, defaults to 20ms
batchingInterval?: number
}
export interface ClientOptions extends Options {
// The username to connect to the server as
username: string
// The view distance in chunks
viewDistance?: number
// Specifies which game edition to sign in as. Optional, but some servers verify this.
authTitle?: string
// How long to wait in milliseconds while trying to connect to the server.
connectTimeout?: number
// whether to skip initial ping and immediately connect
skipPing?: boolean
// Update the options' port parameter to match the port broadcast on the server's ping data (default to true if `realms` not specified)
followPort?: boolean
// where to log connection information to (default to console.log)
conLog?: any
// used to join a Realm instead of supplying a host/port
realms?: RealmsOptions
// the path to store authentication caches, defaults to .minecraft
profilesFolder?: string | false
// Called when microsoft authorization is needed when not provided it will the information log to the console instead
onMsaCode?: (data: ServerDeviceCodeResponse) => void
}
export interface ServerOptions extends Options {
// The maximum number of players allowed on the server at any time. // The maximum number of players allowed on the server at any time.
maxPlayers?: number maxPlayers: number,
motd?: {
motd: {
// The header for the MOTD shown in the server list. // The header for the MOTD shown in the server list.
motd: string motd: string,
// The sub-header for the MOTD shown in the server list. // The sub-header for the MOTD shown in the server list.
levelName?: string levelName: string
} }
advertisementFn?: () => ServerAdvertisement
} }
enum ClientStatus { enum ClientStatus {
Disconnected, Disconected, Authenticating, Initializing, Initialized
Authenticating,
Initializing,
Initialized
} }
export class Connection extends EventEmitter { export class Connection extends EventEmitter {
readonly status: ClientStatus readonly status: ClientStatus
// Check if the passed version is less than or greater than the current connected client version. // Check if the passed version is less than or greater than the current connected client version.
versionLessThan(version: string | number): boolean versionLessThan(version: string | number)
versionGreaterThan(version: string | number): boolean versionGreaterThan(version: string | number)
versionGreaterThanOrEqualTo(version: string | number): boolean
// Writes a Minecraft bedrock packet and sends it without queue batching // Writes a Minecraft bedrock packet and sends it without queue batching
write(name: string, params: object): void write(name: string, params: object)
// Adds a Minecraft bedrock packet to be sent in the next outgoing batch // Adds a Minecraft bedrock packet to be sent in the next outgoing batch
queue(name: string, params: object): void queue(name: string, params: object)
// Writes a MCPE buffer to the connection and skips Protodef serialization. `immediate` if skip queue. // Writes a MCPE buffer to the connection and skips Protodef serialization. `immediate` if skip queue.
sendBuffer(buffer: Buffer, immediate?: boolean): void sendBuffer(buffer: Buffer, immediate?: boolean)
} }
type PlayStatus = type PlayStatus =
| 'login_success' | 'login_success'
// # Displays "Could not connect: Outdated client!" // # Displays "Could not connect: Outdated client!"
| 'failed_client' | 'failed_client'
@ -102,139 +60,80 @@ declare module 'bedrock-protocol' {
// # Displays "Wow this server is popular! Check back later to see if space opens up. Server Full" // # Displays "Wow this server is popular! Check back later to see if space opens up. Server Full"
| 'failed_server_full' | 'failed_server_full'
export class Client extends Connection { export class Client extends Connection {
constructor(options: Options) constructor(options: Options)
// The client's EntityID returned by the server // The client's EntityID returned by the server
readonly entityId: BigInt readonly entityId: BigInt
/** /**
* Close the connection, leave the server. * Close the connection, leave the server.
*/ */
close(reason?: string): void close()
/**
* Send a disconnect packet and close the connection
*/
disconnect(): void
} }
/** /**
* `Player` represents a player connected to the server. * `Player` represents a player connected to the server.
*/ */
export class Player extends Connection { export class Player extends Connection {
profile?: {
xuid: string
uuid: string
name: string
}
version: string
getUserData(): object
/** /**
* Disconnects a client before it has logged in via a PlayStatus packet. * Disconnects a client before it has logged in via a PlayStatus packet.
* @param {string} playStatus * @param {string} playStatus
*/ */
sendDisconnectStatus(playStatus: PlayStatus): void sendDisconnectStatus(playStatus: PlayStatus)
/** /**
* Disconnects a client * Disconnects a client
* @param reason The message to be shown to the user on disconnect * @param reason The message to be shown to the user on disconnect
* @param hide Don't show the client the reason for the disconnect * @param hide Don't show the client the reason for the disconnect
*/ */
disconnect(reason: string, hide?: boolean): void disconnect(reason: string, hide?: boolean)
/** /**
* Close the connection. Already called by disconnect. Call this to manually close RakNet connection. * Close the connection. Already called by disconnect. Call this to manually close RakNet connection.
*/ */
close(): void close()
on(event: 'login', cb: () => void): any
on(event: 'join', cb: () => void): any
on(event: 'close', cb: (reason: string) => void): any
on(event: 'packet', cb: (packet: object) => void): any
on(event: 'spawn', cb: (reason: string) => void): any
} }
export class Server extends EventEmitter { export class Server extends EventEmitter {
clients: Map<string, Player> clients: Map<string, Player>
// Connection logging function
conLog: Function conLog: Function
constructor(options: Options) constructor(options: Options)
// Disconnects all currently connected clients
listen(): Promise<void> close(disconnectReason: string)
close(disconnectReason?: string): Promise<void>
on(event: 'connect', cb: (client: Player) => void): any
} }
type RelayOptions = Options & { type RelayOptions = Options & {
host: string,
port: number,
// Toggle packet logging. // Toggle packet logging.
logging?: boolean logging: boolean,
// Skip authentication for connecting clients?
offline?: false
// Specifies which game edition to sign in as to the destination server. Optional, but some servers verify this.
authTitle?: string
// Where to proxy requests to. // Where to proxy requests to.
destination: { destination: {
realms?: RealmsOptions host: string,
host: string
port: number port: number
// Skip authentication connecting to the remote server?
offline?: boolean
} }
// Whether to enable chunk caching (default: false)
enableChunkCaching?: boolean
// Only allow one client to connect at a time (default: false)
forceSingle?: boolean
// Do not disconnect clients on server packet parsing errors and drop the packet instead (default: false)
omitParseErrors?: boolean
// Dispatched when a new client has logged in, and we need authentication
// tokens to join the backend server. Cached after the first login.
// If this is not specified, the client will be disconnected with a login prompt.
onMsaCode?(data: ServerDeviceCodeResponse, client: Client): any
// prismarine-auth configuration
flow?: string,
deviceType?: string
} }
export class Relay extends Server { export class Relay extends Server {
constructor(options: RelayOptions) constructor(options: RelayOptions)
} }
export class ServerAdvertisement { class ServerAdvertisement {
motd: string motd: string
name: string name: string
protocol: number protocol: number
version: string version: string
playersOnline: number players: {
playersMax: number online: number,
max: number
}
gamemode: string
serverId: string serverId: string
levelName: string
gamemodeId: number
portV4: number
portV6: number
constructor(obj: object, port: number, version: string)
} }
export interface RealmsOptions { export function createClient(options: Options): Client
realmId?: string export function createServer(options: Options): Server
realmInvite?: string
pickRealm?: (realms: Realm[]) => Realm
}
export function createClient(options: ClientOptions): Client export function ping({ host, port }) : ServerAdvertisement
export function createServer(options: ServerOptions): Server }
export function ping({
host,
port
}: {
host: string
port: number
}): Promise<ServerAdvertisement>
}

View file

@ -9,8 +9,7 @@ const { Server } = require('./src/server')
const { Relay } = require('./src/relay') const { Relay } = require('./src/relay')
const { createClient, ping } = require('./src/createClient') const { createClient, ping } = require('./src/createClient')
const { createServer } = require('./src/createServer') const { createServer } = require('./src/createServer')
const { Titles } = require('prismarine-auth') const Title = require('./src/client/titles')
const { ServerAdvertisement } = require('./src/server/advertisement')
module.exports = { module.exports = {
Client, Client,
@ -19,6 +18,5 @@ module.exports = {
createClient, createClient,
ping, ping,
createServer, createServer,
title: Titles, title: Title
ServerAdvertisement
} }

View file

@ -1,15 +1,15 @@
{ {
"name": "bedrock-protocol", "name": "bedrock-protocol",
"version": "3.49.0", "version": "3.2.0",
"description": "Minecraft Bedrock Edition protocol library", "description": "Minecraft Bedrock Edition protocol library",
"main": "index.js", "main": "index.js",
"types": "index.d.ts",
"scripts": { "scripts": {
"build": "cd tools && node compileProtocol.js", "build": "cd tools && node compileProtocol.js",
"test": "mocha --retries 2 --bail --exit", "prepare": "npm run build",
"test": "mocha --bail",
"pretest": "npm run lint", "pretest": "npm run lint",
"lint": "standard", "lint": "standard",
"vanillaServer": "minecraft-bedrock-server --root tools --version", "vanillaServer": "node tools/startVanillaServer.js",
"dumpPackets": "node tools/genPacketDumps.js", "dumpPackets": "node tools/genPacketDumps.js",
"fix": "standard --fix" "fix": "standard --fix"
}, },
@ -21,29 +21,34 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/msal-node": "^1.1.0",
"@xboxreplay/xboxlive-auth": "^3.3.3",
"debug": "^4.3.1", "debug": "^4.3.1",
"jsonwebtoken": "^9.0.0", "jose-node-cjs-runtime": "^3.12.1",
"jsp-raknet": "^2.1.3", "jsonwebtoken": "^8.5.1",
"minecraft-data": "^3.0.0", "jsp-raknet": "^2.1.0",
"minecraft-folder-path": "^1.2.0", "minecraft-folder-path": "^1.1.0",
"prismarine-auth": "^2.0.0", "node-fetch": "^2.6.1",
"prismarine-nbt": "^2.0.0", "prismarine-nbt": "^1.5.0",
"prismarine-realms": "^1.1.0", "protodef": "github:extremeheat/node-protodef#vars",
"protodef": "^1.14.0", "smart-buffer": "^4.1.0",
"raknet-native": "^1.0.3",
"uuid-1345": "^1.0.2" "uuid-1345": "^1.0.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"raknet-node": "^0.5.0" "raknet-native": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"bedrock-protocol": "file:.", "@babel/eslint-parser": "^7.13.10",
"bedrock-provider": "^2.0.0", "bedrock-provider": "^1.0.0",
"leveldb-zlib": "^1.0.1", "babel-eslint": "^10.1.0",
"minecraft-bedrock-server": "^1.4.2", "mocha": "^8.3.2",
"mocha": "^11.0.1",
"protodef-yaml": "^1.1.0", "protodef-yaml": "^1.1.0",
"standard": "^17.0.0-2" "standard": "^16.0.3",
"leveldb-zlib": "^0.0.26",
"bedrock-protocol": "file:."
},
"standard": {
"parser": "babel-eslint"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -1,10 +1,11 @@
const { ClientStatus, Connection } = require('./connection') const { ClientStatus, Connection } = require('./connection')
const { createDeserializer, createSerializer } = require('./transforms/serializer') const { createDeserializer, createSerializer } = require('./transforms/serializer')
const { serialize, isDebug } = require('./datatypes/util') const { RakClient } = require('./rak')
const { serialize } = require('./datatypes/util')
const debug = require('debug')('minecraft-protocol') const debug = require('debug')('minecraft-protocol')
const Options = require('./options') const Options = require('./options')
const auth = require('./client/auth') const auth = require('./client/auth')
const initRaknet = require('./rak')
const { KeyExchange } = require('./handshake/keyExchange') const { KeyExchange } = require('./handshake/keyExchange')
const Login = require('./handshake/login') const Login = require('./handshake/login')
const LoginVerify = require('./handshake/loginVerify') const LoginVerify = require('./handshake/loginVerify')
@ -19,66 +20,33 @@ class Client extends Connection {
constructor (options) { constructor (options) {
super() super()
this.options = { ...Options.defaultOptions, ...options } this.options = { ...Options.defaultOptions, ...options }
this.startGameData = {}
this.clientRuntimeId = null
// Start off without compression on 1.19.30, zlib on below
this.compressionAlgorithm = this.versionGreaterThanOrEqualTo('1.19.30') ? 'none' : 'deflate'
this.compressionThreshold = 512
this.compressionLevel = this.options.compressionLevel
this.batchHeader = 0xfe
if (isDebug) {
this.inLog = (...args) => debug('C ->', ...args)
this.outLog = (...args) => debug('C <-', ...args)
}
this.conLog = this.options.conLog === undefined ? console.log : this.options.conLog
if (!options.delayedInit) {
this.init()
}
}
init () {
this.validateOptions() this.validateOptions()
this.serializer = createSerializer(this.options.version) this.serializer = createSerializer(this.options.version)
this.deserializer = createDeserializer(this.options.version) this.deserializer = createDeserializer(this.options.version)
this._loadFeatures()
KeyExchange(this, null, this.options) KeyExchange(this, null, this.options)
Login(this, null, this.options) Login(this, null, this.options)
LoginVerify(this, null, this.options) LoginVerify(this, null, this.options)
const { RakClient } = initRaknet(this.options.raknetBackend)
const host = this.options.host const host = this.options.host
const port = this.options.port const port = this.options.port
this.connection = new RakClient({ useWorkers: this.options.useRaknetWorkers, host, port }, this) this.connection = new RakClient({ useWorkers: true, host, port })
this.emit('connect_allowed') this.startGameData = {}
} this.clientRuntimeId = null
_loadFeatures () { this.inLog = (...args) => debug('C ->', ...args)
try { this.outLog = (...args) => debug('C <-', ...args)
const mcData = require('minecraft-data')('bedrock_' + this.options.version)
this.features = {
compressorInHeader: mcData.supportFeature('compressorInPacketHeader'),
itemRegistryPacket: mcData.supportFeature('itemRegistryPacket'),
newLoginIdentityFields: mcData.supportFeature('newLoginIdentityFields')
}
} catch (e) {
throw new Error(`Unsupported version: '${this.options.version}', no data available`)
}
} }
connect () { connect () {
if (!this.connection) throw new Error('Connect not currently allowed') // must wait for `connect_allowed`, or use `createClient`
this.on('session', this._connect) this.on('session', this._connect)
if (this.options.offline) { if (this.options.offline) {
debug('offline mode, not authenticating', this.options) console.debug('offline mode, not authenticating', this.options)
auth.createOfflineSession(this, this.options) auth.createOfflineSession(this, this.options)
} else { } else {
auth.authenticate(this, this.options) auth.authenticateDeviceCode(this, this.options)
} }
this.startQueue() this.startQueue()
@ -86,7 +54,15 @@ class Client extends Connection {
validateOptions () { validateOptions () {
if (!this.options.host || this.options.port == null) throw Error('Invalid host/port') if (!this.options.host || this.options.port == null) throw Error('Invalid host/port')
Options.validateOptions(this.options)
if (!Options.Versions[this.options.version]) {
console.warn('Supported versions: ', Options.Versions)
throw Error(`Unsupported version ${this.options.version}`)
}
this.options.protocolVersion = Options.Versions[this.options.version]
if (this.options.protocolVersion < Options.MIN_VERSION) {
throw new Error(`Protocol version < ${Options.MIN_VERSION} : ${this.options.protocolVersion}, too old`)
}
} }
get entityId () { get entityId () {
@ -95,49 +71,33 @@ class Client extends Connection {
onEncapsulated = (encapsulated, inetAddr) => { onEncapsulated = (encapsulated, inetAddr) => {
const buffer = Buffer.from(encapsulated.buffer) const buffer = Buffer.from(encapsulated.buffer)
process.nextTick(() => this.handle(buffer)) this.handle(buffer)
} }
async ping () { async ping () {
try { try {
return await this.connection.ping(this.options.connectTimeout) return await this.connection.ping(this.options.connectTimeout)
} catch (e) { } catch (e) {
this.conLog?.(`Unable to connect to [${this.options.host}]/${this.options.port}. Is the server running?`) console.warn(`Unable to connect to [${this.options.host}]/${this.options.port}. Is the server running?`)
throw e throw e
} }
} }
_connect = async (sessionData) => { _connect = async (sessionData) => {
debug('[client] connecting to', this.options.host, this.options.port, sessionData, this.connection) debug('[client] connecting to', this.options.host, this.options.port, sessionData, this.connection)
this.connection.onConnected = () => { this.connection.onConnected = () => this.sendLogin()
this.status = ClientStatus.Connecting this.connection.onCloseConnection = () => this.close()
if (this.versionGreaterThanOrEqualTo('1.19.30')) {
this.queue('request_network_settings', { client_protocol: this.options.protocolVersion })
} else {
this.sendLogin()
}
}
this.connection.onCloseConnection = (reason) => {
if (this.status === ClientStatus.Disconnected) this.conLog?.(`Server closed connection: ${reason}`)
this.close()
}
this.connection.onEncapsulated = this.onEncapsulated this.connection.onEncapsulated = this.onEncapsulated
this.connection.connect() this.connection.connect()
this.connectTimeout = setTimeout(() => { this.connectTimeout = setTimeout(() => {
if (this.status === ClientStatus.Disconnected) { if (this.status === ClientStatus.Disconnected) {
this.connection.close() this.connection.close()
this.emit('error', Error('Connect timed out')) this.emit('error', 'connect timed out')
} }
}, this.options.connectTimeout || 9000) }, this.options.connectTimeout || 9000)
} }
updateCompressorSettings (packet) {
this.compressionAlgorithm = packet.compression_algorithm || 'deflate'
this.compressionThreshold = packet.compression_threshold
this.compressionReady = true
}
sendLogin () { sendLogin () {
this.status = ClientStatus.Authenticating this.status = ClientStatus.Authenticating
this.createClientChain(null, this.options.offline) this.createClientChain(null, this.options.offline)
@ -147,31 +107,22 @@ class Client extends Connection {
...this.accessToken // Mojang + Xbox JWT from auth ...this.accessToken // Mojang + Xbox JWT from auth
] ]
let encodedChain const encodedChain = JSON.stringify({ chain })
if (this.features.newLoginIdentityFields) { // 1.21.90+ const bodyLength = this.clientUserChain.length + encodedChain.length + 8
encodedChain = JSON.stringify({
Certificate: JSON.stringify({ chain }), debug('Auth chain', chain)
// 0 = normal, 1 = ss, 2 = offline
AuthenticationType: this.options.offline ? 2 : 0,
Token: ''
})
} else {
encodedChain = JSON.stringify({ chain })
}
debug('Auth chain', encodedChain)
this.write('login', { this.write('login', {
protocol_version: this.options.protocolVersion, protocol_version: this.options.protocolVersion,
tokens: { payload_size: bodyLength,
identity: encodedChain, chain: encodedChain,
client: this.clientUserChain client_data: this.clientUserChain
}
}) })
this.emit('loggingIn') this.emit('loggingIn')
} }
onDisconnectRequest (packet) { onDisconnectRequest (packet) {
this.conLog?.(`Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`) console.warn(`C Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`)
this.emit('kick', packet) this.emit('kick', packet)
this.close() this.close()
} }
@ -180,31 +131,16 @@ class Client extends Connection {
if (this.status === ClientStatus.Initializing && this.options.autoInitPlayer === true) { if (this.status === ClientStatus.Initializing && this.options.autoInitPlayer === true) {
if (statusPacket.status === 'player_spawn') { if (statusPacket.status === 'player_spawn') {
this.status = ClientStatus.Initialized this.status = ClientStatus.Initialized
if (!this.entityId) { this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId })
// We need to wait for start_game in the rare event we get a player_spawn before start_game race condition
this.on('start_game', () => this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId }))
} else {
this.write('set_local_player_as_initialized', { runtime_entity_id: this.entityId })
}
this.emit('spawn') this.emit('spawn')
} }
} }
} }
disconnect (reason = 'Client leaving', hide = false) {
if (this.status === ClientStatus.Disconnected) return
this.write('disconnect', {
hide_disconnect_screen: hide,
message: reason,
filtered_message: ''
})
this.close(reason)
}
close () { close () {
if (this.status !== ClientStatus.Disconnected) { if (this.status !== ClientStatus.Disconnected) {
this.emit('close') // Emit close once this.emit('close') // Emit close once
debug('Client closed!') console.log('Client closed!')
} }
clearInterval(this.loop) clearInterval(this.loop)
clearTimeout(this.connectTimeout) clearTimeout(this.connectTimeout)
@ -216,16 +152,9 @@ class Client extends Connection {
} }
readPacket (packet) { readPacket (packet) {
try { const des = this.deserializer.parsePacketBuffer(packet)
var des = this.deserializer.parsePacketBuffer(packet) // eslint-disable-line
} catch (e) {
// Dump information about the packet only if user is not handling error event.
if (this.listenerCount('error') === 0) this.deserializer.dumpFailedBuffer(packet)
this.emit('error', e)
return
}
const pakData = { name: des.data.name, params: des.data.params } const pakData = { name: des.data.name, params: des.data.params }
this.inLog?.('-> C', pakData.name, this.options.logging ? serialize(pakData.params) : '') this.inLog('-> C', pakData.name, this.options.loggging ? serialize(pakData.params) : '')
this.emit('packet', des) this.emit('packet', des)
if (debugging) { if (debugging) {
@ -240,21 +169,13 @@ class Client extends Connection {
case 'server_to_client_handshake': case 'server_to_client_handshake':
this.emit('client.server_handshake', des.data.params) this.emit('client.server_handshake', des.data.params)
break break
case 'network_settings':
this.updateCompressorSettings(des.data.params)
if (this.status === ClientStatus.Connecting) {
this.sendLogin()
}
break
case 'disconnect': // Client kicked case 'disconnect': // Client kicked
this.emit(des.data.name, des.data.params) // Emit before we kill all listeners. this.emit(des.data.name, des.data.params) // Emit before we kill all listeners.
this.onDisconnectRequest(des.data.params) this.onDisconnectRequest(des.data.params)
break break
case 'start_game': case 'start_game':
this.startGameData = pakData.params this.startGameData = pakData.params
// fallsthrough this.startGameData.itemstates.forEach(state => {
case 'item_registry': // 1.21.60+ send itemstates in item_registry packet
pakData.params.itemstates?.forEach(state => {
if (state.name === 'minecraft:shield') { if (state.name === 'minecraft:shield') {
this.serializer.proto.setVariable('ShieldItemID', state.runtime_id) this.serializer.proto.setVariable('ShieldItemID', state.runtime_id)
this.deserializer.proto.setVariable('ShieldItemID', state.runtime_id) this.deserializer.proto.setVariable('ShieldItemID', state.runtime_id)
@ -263,7 +184,7 @@ class Client extends Connection {
break break
case 'play_status': case 'play_status':
if (this.status === ClientStatus.Authenticating) { if (this.status === ClientStatus.Authenticating) {
this.inLog?.('Server wants to skip encryption') this.inLog('Server wants to skip encryption')
this.emit('join') this.emit('join')
this.status = ClientStatus.Initializing this.status = ClientStatus.Initializing
} }
@ -271,7 +192,7 @@ class Client extends Connection {
break break
default: default:
if (this.status !== ClientStatus.Initializing && this.status !== ClientStatus.Initialized) { if (this.status !== ClientStatus.Initializing && this.status !== ClientStatus.Initialized) {
this.inLog?.(`Can't accept ${des.data.name}, client not yet authenticated : ${this.status}`) this.inLog(`Can't accept ${des.data.name}, client not yet authenticated : ${this.status}`)
return return
} }
} }

View file

@ -1,95 +1,31 @@
const path = require('path') const { MsAuthFlow } = require('./authFlow.js')
const { Authflow: PrismarineAuth, Titles } = require('prismarine-auth')
const minecraftFolderPath = require('minecraft-folder-path')
const debug = require('debug')('minecraft-protocol')
const { uuidFrom } = require('../datatypes/util') const { uuidFrom } = require('../datatypes/util')
const { RealmAPI } = require('prismarine-realms')
function validateOptions (options) {
if (!options.profilesFolder) {
options.profilesFolder = path.join(minecraftFolderPath, 'nmp-cache')
}
if (options.authTitle === undefined) {
options.authTitle = Titles.MinecraftNintendoSwitch
options.deviceType = 'Nintendo'
options.flow = 'live'
}
}
async function realmAuthenticate (options) {
validateOptions(options)
options.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const api = RealmAPI.from(options.authflow, 'bedrock')
const getRealms = async () => {
const realms = await api.getRealms()
debug('realms', realms)
if (!realms.length) throw Error('Couldn\'t find any Realms for the authenticated account')
return realms
}
let realm
if (options.realms.realmId) {
const realms = await getRealms()
realm = realms.find(e => e.id === Number(options.realms.realmId))
} else if (options.realms.realmInvite) {
realm = await api.getRealmFromInvite(options.realms.realmInvite)
} else if (options.realms.pickRealm) {
if (typeof options.realms.pickRealm !== 'function') throw Error('realms.pickRealm must be a function')
const realms = await getRealms()
realm = await options.realms.pickRealm(realms)
}
if (!realm) throw Error('Couldn\'t find a Realm to connect to. Authenticated account must be the owner or has been invited to the Realm.')
const { host, port } = await realm.getAddress()
debug('realms connection', { host, port })
options.host = host
options.port = port
}
/** /**
* Authenticates to Minecraft via device code based Microsoft auth, * Obtains Minecaft profile data using a Minecraft access token and starts the join sequence
* then connects to the specified server in Client Options
*
* @function
* @param {object} client - The client passed to protocol * @param {object} client - The client passed to protocol
* @param {object} options - Client Options * @param {object} options - Client Options
* @param {string} chains - Minecraft JWTs to send to server
*/ */
async function authenticate (client, options) { async function postAuthenticate (client, options, chains) {
validateOptions(options) // First chain is Mojang stuff, second is Xbox profile data used by mc
try { const jwt = chains[1]
const authflow = options.authflow || new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) const [header, payload, signature] = jwt.split('.').map(k => Buffer.from(k, 'base64')) // eslint-disable-line
const chains = await authflow.getMinecraftBedrockToken(client.clientX509).catch(e => { const xboxProfile = JSON.parse(String(payload))
if (options.password) console.warn('Sign in failed, try removing the password field')
throw e
})
debug('chains', chains) // This profile / session here could be simplified down to where it just passes the uuid of the player to encrypt.js
// That way you could remove some lines of code. It accesses client.session.selectedProfile.id so /shrug.
// First chain is Mojang stuff, second is Xbox profile data used by mc // - Kashalls
const jwt = chains[1] const profile = {
const [header, payload, signature] = jwt.split('.').map(k => Buffer.from(k, 'base64')) // eslint-disable-line name: xboxProfile?.extraData?.displayName || 'Player',
const xboxProfile = JSON.parse(String(payload)) uuid: xboxProfile?.extraData?.identity || 'adfcf5ca-206c-404a-aec4-f59fff264c9b', // random
xuid: xboxProfile?.extraData?.XUID || 0
debug('got xbox profile', xboxProfile)
const profile = {
name: xboxProfile?.extraData?.displayName || 'Player',
uuid: xboxProfile?.extraData?.identity || 'adfcf5ca-206c-404a-aec4-f59fff264c9b', // random
xuid: xboxProfile?.extraData?.XUID || 0
}
return postAuthenticate(client, profile, chains)
} catch (err) {
console.error(err)
client.emit('error', err)
} }
client.profile = profile
client.username = profile.name
client.accessToken = chains
client.emit('session', profile)
} }
/** /**
@ -102,18 +38,65 @@ function createOfflineSession (client, options) {
uuid: uuidFrom(options.username), // random uuid: uuidFrom(options.username), // random
xuid: 0 xuid: 0
} }
return postAuthenticate(client, profile, []) // No extra JWTs, only send 1 client signed chain with all the data
}
function postAuthenticate (client, profile, chains) {
client.profile = profile client.profile = profile
client.username = profile.name client.username = profile.name
client.accessToken = chains client.accessToken = [] // No extra JWTs, only send 1 client signed chain with all the data
client.emit('session', profile) client.emit('session', profile)
} }
/**
* Authenticates with Mincrosoft through user credentials, then
* with Xbox Live, Minecraft, checks entitlements and returns profile
*
* @function
* @param {object} client - The client passed to protocol
* @param {object} options - Client Options
*/
async function authenticatePassword (client, options) {
throw Error('Not implemented')
}
/**
* Authenticates to Minecraft via device code based Microsoft auth,
* then connects to the specified server in Client Options
*
* @function
* @param {object} client - The client passed to protocol
* @param {object} options - Client Options
*/
async function authenticateDeviceCode (client, options) {
try {
const flow = new MsAuthFlow(options.username, options.profilesFolder, options, options.onMsaCode)
const chain = await flow.getMinecraftToken(client.clientX509)
// console.log('Chain', chain)
await postAuthenticate(client, options, chain)
} catch (err) {
console.error(err)
client.emit('error', err)
}
}
module.exports = { module.exports = {
createOfflineSession, createOfflineSession,
authenticate, authenticatePassword,
realmAuthenticate authenticateDeviceCode
} }
// async function msaTest () {
// // MsAuthFlow.resetTokenCaches()
// await authenticateDeviceCode({
// connect(...args) {
// console.log('Connecting', args)
// },
// emit(...e) {
// console.log('Event', e)
// }
// }, {})
// }
// // debug with node microsoftAuth.js
// if (!module.parent) {
// msaTest()
// }

View file

@ -0,0 +1,10 @@
module.exports = {
XSTSRelyingParty: 'https://multiplayer.minecraft.net/',
MinecraftAuth: 'https://multiplayer.minecraft.net/authentication',
XboxDeviceAuth: 'https://device.auth.xboxlive.com/device/authenticate',
XboxTitleAuth: 'https://title.auth.xboxlive.com/title/authenticate',
XstsAuthorize: 'https://xsts.auth.xboxlive.com/xsts/authorize',
LiveDeviceCodeRequest: 'https://login.live.com/oauth20_connect.srf',
LiveTokenRequest: 'https://login.live.com/oauth20_token.srf'
}

162
src/client/authFlow.js Normal file
View file

@ -0,0 +1,162 @@
const crypto = require('crypto')
const path = require('path')
const fs = require('fs')
const debug = require('debug')('minecraft-protocol')
const mcDefaultFolderPath = require('minecraft-folder-path')
const authConstants = require('./authConstants')
const { LiveTokenManager, MsaTokenManager, XboxTokenManager, MinecraftTokenManager } = require('./tokens')
// Initialize msal
// Docs: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/request.md#public-apis-1
const msalConfig = {
auth: {
// the minecraft client:
// clientId: "000000004C12AE6F",
clientId: '389b1b32-b5d5-43b2-bddc-84ce938d6737', // token from https://github.com/microsoft/Office365APIEditor
authority: 'https://login.microsoftonline.com/consumers'
}
}
async function retry (methodFn, beforeRetry, times) {
while (times--) {
if (times !== 0) {
try { return await methodFn() } catch (e) { debug(e) }
await new Promise(resolve => setTimeout(resolve, 2000))
await beforeRetry()
} else {
return await methodFn()
}
}
}
class MsAuthFlow {
constructor (username, cacheDir, options = {}, codeCallback) {
this.options = options
this.initTokenCaches(username, cacheDir)
this.codeCallback = codeCallback
}
initTokenCaches (username, cacheDir) {
const hash = sha1(username).substr(0, 6)
let cachePath = cacheDir || mcDefaultFolderPath
try {
if (!fs.existsSync(cachePath + '/nmp-cache')) {
fs.mkdirSync(cachePath + '/nmp-cache')
}
cachePath += '/nmp-cache'
} catch (e) {
console.log('Failed to open cache dir', e)
cachePath = __dirname
}
const cachePaths = {
live: path.join(cachePath, `./${hash}_live-cache.json`),
msa: path.join(cachePath, `./${hash}_msa-cache.json`),
xbl: path.join(cachePath, `./${hash}_xbl-cache.json`),
bed: path.join(cachePath, `./${hash}_bed-cache.json`)
}
if (this.options.authTitle) { // Login with login.live.com
const scopes = ['service::user.auth.xboxlive.com::MBI_SSL']
this.msa = new LiveTokenManager(this.options.authTitle, scopes, cachePaths.live)
} else { // Login with microsoftonline.com
const scopes = ['XboxLive.signin', 'offline_access']
this.msa = new MsaTokenManager(msalConfig, scopes, cachePaths.msa)
}
const keyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' })
this.xbl = new XboxTokenManager(authConstants.XSTSRelyingParty, keyPair, cachePaths.xbl)
this.mca = new MinecraftTokenManager(cachePaths.bed)
}
static resetTokenCaches (cacheDir) {
let cachePath = cacheDir || mcDefaultFolderPath
try {
if (fs.existsSync(cachePath + '/nmp-cache')) {
cachePath += '/nmp-cache'
fs.rmdirSync(cachePath, { recursive: true })
return true
}
} catch (e) {
console.log('Failed to clear cache dir', e)
return false
}
}
async getMsaToken () {
if (await this.msa.verifyTokens()) {
debug('[msa] Using existing tokens')
return this.msa.getAccessToken().token
} else {
debug('[msa] No valid cached tokens, need to sign in')
const ret = await this.msa.authDeviceCode((response) => {
console.info('[msa] First time signing in. Please authenticate now:')
console.info(response.message)
if (this.codeCallback) this.codeCallback(response)
})
if (ret.account) {
console.info(`[msa] Signed in as ${ret.account.username}`)
} else { // We don't get extra account data here per scope
console.info('[msa] Signed in with Microsoft')
}
debug('[msa] got auth result', ret)
return ret.accessToken
}
}
async getXboxToken () {
if (await this.xbl.verifyTokens()) {
debug('[xbl] Using existing XSTS token')
return this.xbl.getCachedXstsToken().data
} else {
debug('[xbl] Need to obtain tokens')
return await retry(async () => {
const msaToken = await this.getMsaToken()
const ut = await this.xbl.getUserToken(msaToken, !this.options.authTitle)
if (this.options.authTitle) {
const deviceToken = await this.xbl.getDeviceToken({ DeviceType: 'Nintendo', Version: '0.0.0' })
const titleToken = await this.xbl.getTitleToken(msaToken, deviceToken)
const xsts = await this.xbl.getXSTSToken(ut, deviceToken, titleToken)
return xsts
} else {
const xsts = await this.xbl.getXSTSToken(ut)
return xsts
}
}, () => { this.msa.forceRefresh = true }, 2)
}
}
async getMinecraftToken (publicKey) {
// TODO: Fix cache, in order to do cache we also need to cache the ECDH keys so disable it
// is this even a good idea to cache?
if (await this.mca.verifyTokens() && false) { // eslint-disable-line
debug('[mc] Using existing tokens')
return this.mca.getCachedAccessToken().chain
} else {
if (!publicKey) throw new Error('Need to specifiy a ECDH x509 URL encoded public key')
debug('[mc] Need to obtain tokens')
return await retry(async () => {
const xsts = await this.getXboxToken()
debug('[xbl] xsts data', xsts)
const token = await this.mca.getAccessToken(publicKey, xsts)
// If we want to auth with a title ID, make sure there's a TitleID in the response
const body = JSON.parse(Buffer.from(token.chain[1].split('.')[1], 'base64').toString())
console.log(this.options.authTitle)
if (!body.extraData.titleId && this.options.authTitle) {
throw Error('missing titleId in response')
}
return token.chain
}, () => { this.xbl.forceRefresh = true }, 2)
}
}
}
function sha1 (data) {
return crypto.createHash('sha1').update(data || '', 'binary').digest('hex')
}
module.exports = { MsAuthFlow }

4
src/client/titles.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
MinecraftNintendoSwitch: '00000000441cc96b',
MinecraftJava: '00000000402b5328'
}

592
src/client/tokens.js Normal file
View file

@ -0,0 +1,592 @@
const msal = require('@azure/msal-node')
const XboxLiveAuth = require('@xboxreplay/xboxlive-auth')
const debug = require('debug')('minecraft-protocol')
const fs = require('fs')
const path = require('path')
const fetch = require('node-fetch')
const authConstants = require('./authConstants')
const crypto = require('crypto')
const { nextUUID } = require('../datatypes/util')
const { SmartBuffer } = require('smart-buffer')
const jose = require('jose-node-cjs-runtime/jwk/from_key_like')
class LiveTokenManager {
constructor (clientId, scopes, cacheLocation) {
this.clientId = clientId
this.scopes = scopes
this.cacheLocation = cacheLocation
this.reloadCache()
}
reloadCache () {
try {
this.cache = require(this.cacheLocation)
} catch (e) {
this.cache = {}
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache))
}
}
async verifyTokens () {
if (this.forceRefresh) try { await this.refreshTokens() } catch { }
const at = this.getAccessToken()
const rt = this.getRefreshToken()
if (!at || !rt) {
return false
}
debug('[live] have at, rt', at, rt)
if (at.valid && rt) {
return true
} else {
try {
await this.refreshTokens()
return true
} catch (e) {
console.warn('Error refreshing token', e) // TODO: looks like an error happens here
return false
}
}
}
async refreshTokens () {
const rtoken = this.getRefreshToken()
if (!rtoken) {
throw new Error('Cannot refresh without refresh token')
}
const codeRequest = {
method: 'post',
body: new URLSearchParams({ scope: this.scopes, client_id: this.clientId, grant_type: 'refresh_token', refresh_token: rtoken.token }).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include' // This cookie handler does not work on node-fetch ...
}
const token = await fetch(authConstants.LiveTokenRequest, codeRequest).then(checkStatus)
this.updateCachce(token)
return token
}
getAccessToken () {
const token = this.cache.token
if (!token) return
const until = new Date(token.obtainedOn + token.expires_in) - Date.now()
const valid = until > 1000
return { valid, until: until, token: token.access_token }
}
getRefreshToken () {
const token = this.cache.token
if (!token) return
const until = new Date(token.obtainedOn + token.expires_in) - Date.now()
const valid = until > 1000
return { valid, until: until, token: token.refresh_token }
}
updateCachce (data) {
data.obtainedOn = Date.now()
this.cache.token = data
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache))
}
async authDeviceCode (deviceCodeCallback) {
const acquireTime = Date.now()
const codeRequest = {
method: 'post',
body: new URLSearchParams({ scope: this.scopes, client_id: this.clientId, response_type: 'device_code' }).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include' // This cookie handler does not work on node-fetch ...
}
debug('Requesting live device token', codeRequest)
const cookies = []
const res = await fetch(authConstants.LiveDeviceCodeRequest, codeRequest)
.then(res => {
if (res.status !== 200) {
res.text().then(console.warn)
throw Error('Failed to request live.com device code')
}
for (const cookie of Object.values(res.headers.raw()['set-cookie'])) {
const [keyval] = cookie.split(';')
cookies.push(keyval)
}
return res
})
.then(checkStatus).then(resp => {
resp.message = `To sign in, use a web browser to open the page ${resp.verification_uri} and enter the code ${resp.user_code} to authenticate.`
deviceCodeCallback(resp)
return resp
})
const expireTime = acquireTime + (res.expires_in * 1000) - 100 /* for safety */
this.polling = true
while (this.polling && expireTime > Date.now()) {
await new Promise(resolve => setTimeout(resolve, res.interval * 1000))
try {
const verifi = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookies.join('; ')
},
body: new URLSearchParams({
client_id: this.clientId,
device_code: res.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
}).toString()
}
const token = await fetch(authConstants.LiveTokenRequest + '?client_id=' + this.clientId, verifi)
.then(res => res.json()).then(res => {
if (res.error) {
if (res.error === 'authorization_pending') {
debug('[live] Still waiting:', res.error_description)
} else {
throw Error(`Failed to acquire authorization code from device token (${res.error}) - ${res.error_description}`)
}
} else {
return res
}
})
if (!token) continue
this.updateCachce(token)
this.polling = false
return { accessToken: token.access_token }
} catch (e) {
console.debug(e)
}
}
this.polling = false
throw Error('Authenitcation failed, timed out')
}
}
// Manages Microsoft account tokens
class MsaTokenManager {
constructor (msalConfig, scopes, cacheLocation) {
this.msaClientId = msalConfig.auth.clientId
this.scopes = scopes
this.cacheLocation = cacheLocation || path.join(__dirname, './msa-cache.json')
this.reloadCache()
const beforeCacheAccess = async (cacheContext) => {
cacheContext.tokenCache.deserialize(await fs.promises.readFile(this.cacheLocation, 'utf-8'))
}
const afterCacheAccess = async (cacheContext) => {
if (cacheContext.cacheHasChanged) {
await fs.promises.writeFile(this.cacheLocation, cacheContext.tokenCache.serialize())
}
}
const cachePlugin = {
beforeCacheAccess,
afterCacheAccess
}
msalConfig.cache = {
cachePlugin
}
this.msalApp = new msal.PublicClientApplication(msalConfig)
this.msalConfig = msalConfig
}
reloadCache () {
try {
this.msaCache = require(this.cacheLocation)
} catch (e) {
this.msaCache = {}
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.msaCache))
}
}
getUsers () {
const accounts = this.msaCache.Account
const users = []
if (!accounts) return users
for (const account of Object.values(accounts)) {
users.push(account)
}
return users
}
getAccessToken () {
const tokens = this.msaCache.AccessToken
if (!tokens) return
const account = Object.values(tokens).filter(t => t.client_id === this.msaClientId)[0]
if (!account) {
debug('[msa] No valid access token found', tokens)
return
}
const until = new Date(account.expires_on * 1000) - Date.now()
const valid = until > 1000
return { valid, until: until, token: account.secret }
}
getRefreshToken () {
const tokens = this.msaCache.RefreshToken
if (!tokens) return
const account = Object.values(tokens).filter(t => t.client_id === this.msaClientId)[0]
if (!account) {
debug('[msa] No valid refresh token found', tokens)
return
}
return { token: account.secret }
}
async refreshTokens () {
const rtoken = this.getRefreshToken()
if (!rtoken) {
throw new Error('Cannot refresh without refresh token')
}
const refreshTokenRequest = {
refreshToken: rtoken.token,
scopes: this.scopes
}
return new Promise((resolve, reject) => {
this.msalApp.acquireTokenByRefreshToken(refreshTokenRequest).then((response) => {
debug('[msa] refreshed token', JSON.stringify(response))
this.reloadCache()
resolve(response)
}).catch((error) => {
debug('[msa] failed to refresh', JSON.stringify(error))
reject(error)
})
})
}
async verifyTokens () {
if (this.forceRefresh) try { await this.refreshTokens() } catch { }
const at = this.getAccessToken()
const rt = this.getRefreshToken()
if (!at || !rt) {
return false
}
debug('[msa] have at, rt', at, rt)
if (at.valid && rt) {
return true
} else {
try {
await this.refreshTokens()
return true
} catch (e) {
console.warn('Error refreshing token', e) // TODO: looks like an error happens here
return false
}
}
}
// Authenticate with device_code flow
async authDeviceCode (dataCallback) {
const deviceCodeRequest = {
deviceCodeCallback: (resp) => {
debug('[msa] device_code response: ', resp)
dataCallback(resp)
},
scopes: this.scopes
}
return new Promise((resolve, reject) => {
this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest).then((response) => {
debug('[msa] device_code resp', JSON.stringify(response))
if (!this.msaCache.Account) this.msaCache.Account = { '': response.account }
resolve(response)
}).catch((error) => {
console.warn('[msa] Error getting device code')
console.debug(JSON.stringify(error))
reject(error)
})
})
}
}
// Manages Xbox Live tokens for xboxlive.com
class XboxTokenManager {
constructor (relyingParty, ecKey, cacheLocation) {
this.relyingParty = relyingParty
this.key = ecKey
jose.fromKeyLike(ecKey.publicKey).then(jwk => {
this.jwk = { ...jwk, alg: 'ES256', use: 'sig' }
})
this.cacheLocation = cacheLocation || path.join(__dirname, './xbl-cache.json')
try {
this.cache = require(this.cacheLocation)
} catch (e) {
this.cache = {}
}
this.headers = { 'Cache-Control': 'no-store, must-revalidate, no-cache', 'x-xbl-contract-version': 1 }
}
getCachedUserToken () {
const token = this.cache.userToken
if (!token) return
const until = new Date(token.NotAfter)
const dn = Date.now()
const remainingMs = until - dn
const valid = remainingMs > 1000
return { valid, token: token.Token, data: token }
}
getCachedXstsToken () {
const token = this.cache.xstsToken
if (!token) return
const until = new Date(token.expiresOn)
const dn = Date.now()
const remainingMs = until - dn
const valid = remainingMs > 1000
return { valid, token: token.XSTSToken, data: token }
}
setCachedUserToken (data) {
this.cache.userToken = data
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache))
}
setCachedXstsToken (data) {
this.cache.xstsToken = data
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache))
}
async verifyTokens () {
const ut = this.getCachedUserToken()
const xt = this.getCachedXstsToken()
if (!ut || !xt || this.forceRefresh) {
return false
}
debug('[xbl] have user, xsts', ut, xt)
if (ut.valid && xt.valid) {
return true
} else if (ut.valid && !xt.valid) {
try {
await this.getXSTSToken(ut.data)
return true
} catch (e) {
return false
}
}
return false
}
async getUserToken (msaAccessToken, azure) {
debug('[xbl] obtaining xbox token with ms token', msaAccessToken)
msaAccessToken = (azure ? 'd=' : 't=') + msaAccessToken
const xblUserToken = await XboxLiveAuth.exchangeRpsTicketForUserToken(msaAccessToken)
this.setCachedUserToken(xblUserToken)
debug('[xbl] user token:', xblUserToken)
return xblUserToken
}
// Make signature for the data being sent to server with our private key; server is sent our public key in plaintext
sign (url, authorizationToken, payload) {
// Their backend servers use Windows epoch timestamps, account for that. The server is very picky,
// bad percision or wrong epoch may fail the request.
const windowsTimestamp = (BigInt((Date.now() / 1000) | 0) + 11644473600n) * 10000000n
// Only the /uri?and-query-string
const pathAndQuery = new URL(url).pathname
// Allocate the buffer for signature, TS, path, tokens and payload and NUL termination
const allocSize = /* sig */ 5 + /* ts */ 9 + /* POST */ 5 + pathAndQuery.length + 1 + authorizationToken.length + 1 + payload.length + 1
const buf = SmartBuffer.fromSize(allocSize)
buf.writeInt32BE(1) // Policy Version
buf.writeUInt8(0)
buf.writeBigUInt64BE(windowsTimestamp)
buf.writeUInt8(0) // null term
buf.writeStringNT('POST')
buf.writeStringNT(pathAndQuery)
buf.writeStringNT(authorizationToken)
buf.writeStringNT(payload)
// Get the signature from the payload
const signature = crypto.sign('SHA256', buf.toBuffer(), { key: this.key.privateKey, dsaEncoding: 'ieee-p1363' })
const header = SmartBuffer.fromSize(signature.length + 12)
header.writeInt32BE(1) // Policy Version
header.writeBigUInt64BE(windowsTimestamp)
header.writeBuffer(signature) // Add signature at end of header
return header.toBuffer()
}
// If we don't need Xbox Title Authentication, we can have xboxreplay lib
// handle the auth, otherwise we need to build the request ourselves with
// the extra token data.
async getXSTSToken (xblUserToken, deviceToken, titleToken) {
if (deviceToken && titleToken) return this.getXSTSTokenWithTitle(xblUserToken, deviceToken, titleToken)
debug('[xbl] obtaining xsts token with xbox user token (with XboxReplay)', xblUserToken.Token)
const xsts = await XboxLiveAuth.exchangeUserTokenForXSTSIdentity(xblUserToken.Token, { XSTSRelyingParty: this.relyingParty, raw: false })
this.setCachedXstsToken(xsts)
debug('[xbl] xsts', xsts)
return xsts
}
async getXSTSTokenWithTitle (xblUserToken, deviceToken, titleToken, optionalDisplayClaims) {
const userToken = xblUserToken.Token
debug('[xbl] obtaining xsts token with xbox user token', userToken)
const payload = {
RelyingParty: this.relyingParty,
TokenType: 'JWT',
Properties: {
UserTokens: [userToken],
DeviceToken: deviceToken,
TitleToken: titleToken,
OptionalDisplayClaims: optionalDisplayClaims,
ProofKey: this.jwk,
SandboxId: 'RETAIL'
}
}
const body = JSON.stringify(payload)
const signature = this.sign(authConstants.XstsAuthorize, '', body).toString('base64')
const headers = { ...this.headers, Signature: signature }
const ret = await fetch(authConstants.XstsAuthorize, { method: 'post', headers, body }).then(checkStatus)
const xsts = {
userXUID: ret.DisplayClaims.xui[0].xid || null,
userHash: ret.DisplayClaims.xui[0].uhs,
XSTSToken: ret.Token,
expiresOn: ret.NotAfter
}
this.setCachedXstsToken(xsts)
debug('[xbl] xsts', xsts)
return xsts
}
/**
* Requests an Xbox Live-related device token that uniquely links the XToken (aka xsts token)
* @param {{ DeviceType, Version }} asDevice The hardware type and version to auth as, for example Android or Nintendo
*/
async getDeviceToken (asDevice) {
const payload = {
Properties: {
AuthMethod: 'ProofOfPossession',
Id: `{${nextUUID()}}`,
DeviceType: asDevice.DeviceType || 'Android',
SerialNumber: `{${nextUUID()}}`,
Version: asDevice.Version || '10',
ProofKey: this.jwk
},
RelyingParty: 'http://auth.xboxlive.com',
TokenType: 'JWT'
}
const body = JSON.stringify(payload)
const signature = this.sign(authConstants.XboxDeviceAuth, '', body).toString('base64')
const headers = { ...this.headers, Signature: signature }
const ret = await fetch(authConstants.XboxDeviceAuth, { method: 'post', headers, body }).then(checkStatus)
debug('Xbox Device Token', ret)
return ret.Token
}
// This *only* works with live.com auth
async getTitleToken (msaAccessToken, deviceToken) {
const payload = {
Properties: {
AuthMethod: 'RPS',
DeviceToken: deviceToken,
RpsTicket: 't=' + msaAccessToken,
SiteName: 'user.auth.xboxlive.com',
ProofKey: this.jwk
},
RelyingParty: 'http://auth.xboxlive.com',
TokenType: 'JWT'
}
const body = JSON.stringify(payload)
const signature = this.sign(authConstants.XboxTitleAuth, '', body).toString('base64')
const headers = { ...this.headers, Signature: signature }
const ret = await fetch(authConstants.XboxTitleAuth, { method: 'post', headers, body }).then(checkStatus)
debug('Xbox Title Token', ret)
return ret.Token
}
}
// Manages Minecraft tokens for sessionserver.mojang.com
class MinecraftTokenManager {
constructor (clientPublicKey, cacheLocation) {
this.clientPublicKey = clientPublicKey
this.cacheLocation = cacheLocation || path.join(__dirname, './bed-cache.json')
try {
this.cache = require(this.cacheLocation)
} catch (e) {
this.cache = {}
}
}
getCachedAccessToken () {
const token = this.cache.mca
debug('[mc] token cache', this.cache)
if (!token) return
debug('Auth token', token)
const jwt = token.chain[0]
const [header, payload, signature] = jwt.split('.').map(k => Buffer.from(k, 'base64')) // eslint-disable-line
const body = JSON.parse(String(payload))
const expires = new Date(body.exp * 1000)
const remainingMs = expires - Date.now()
const valid = remainingMs > 1000
return { valid, until: expires, chain: token.chain }
}
setCachedAccessToken (data) {
data.obtainedOn = Date.now()
this.cache.mca = data
fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache))
}
async verifyTokens () {
const at = this.getCachedAccessToken()
if (!at || this.forceRefresh) {
return false
}
debug('[mc] have user access token', at)
if (at.valid) {
return true
}
return false
}
async getAccessToken (clientPublicKey, xsts) {
debug('[mc] authing to minecraft', clientPublicKey, xsts)
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'node-minecraft-protocol',
Authorization: `XBL3.0 x=${xsts.userHash};${xsts.XSTSToken}`
}
const MineServicesResponse = await fetch(authConstants.MinecraftAuth, {
method: 'post',
headers,
body: JSON.stringify({ identityPublicKey: clientPublicKey })
}).then(checkStatus)
debug('[mc] mc auth response', MineServicesResponse)
this.setCachedAccessToken(MineServicesResponse)
return MineServicesResponse
}
}
function checkStatus (res) {
if (res.ok) { // res.status >= 200 && res.status < 300
return res.json()
} else {
debug('Request fail', res)
throw Error(res.statusText)
}
}
module.exports = { LiveTokenManager, MsaTokenManager, XboxTokenManager, MinecraftTokenManager }

View file

@ -1,15 +1,14 @@
const Framer = require('./transforms/framer')
const cipher = require('./transforms/encryption') const cipher = require('./transforms/encryption')
const { EventEmitter } = require('events') const { EventEmitter } = require('events')
const { Versions } = require('./options') const { Versions } = require('./options')
const debug = require('debug')('minecraft-protocol') const debug = require('debug')('minecraft-protocol')
const { Framer } = require('./transforms/framer')
const ClientStatus = { const ClientStatus = {
Disconnected: 0, Disconnected: 0,
Connecting: 1, Authenticating: 1, // Handshaking
Authenticating: 2, // Handshaking Initializing: 2, // Authed, need to spawn
Initializing: 3, // Authed, need to spawn Initialized: 3 // play_status spawn sent by server, client responded with SetPlayerInit packet
Initialized: 4 // play_status spawn sent by server, client responded with SetPlayerInit packet
} }
class Connection extends EventEmitter { class Connection extends EventEmitter {
@ -23,33 +22,20 @@ class Connection extends EventEmitter {
set status (val) { set status (val) {
debug('* new status', val) debug('* new status', val)
this.emit('status', val)
this.#status = val this.#status = val
} }
versionLessThan (version) { versionLessThan (version) {
if (typeof version === 'string' && !Versions[version]) throw Error('Unknown version: ' + version)
return this.options.protocolVersion < (typeof version === 'string' ? Versions[version] : version) return this.options.protocolVersion < (typeof version === 'string' ? Versions[version] : version)
} }
versionGreaterThan (version) { versionGreaterThan (version) {
if (typeof version === 'string' && !Versions[version]) throw Error('Unknown version: ' + version)
return this.options.protocolVersion > (typeof version === 'string' ? Versions[version] : version) return this.options.protocolVersion > (typeof version === 'string' ? Versions[version] : version)
} }
versionGreaterThanOrEqualTo (version) {
if (typeof version === 'string' && !Versions[version]) throw Error('Unknown version: ' + version)
return this.options.protocolVersion >= (typeof version === 'string' ? Versions[version] : version)
}
versionLessThanOrEqualTo (version) {
if (typeof version === 'string' && !Versions[version]) throw Error('Unknown version: ' + version)
return this.options.protocolVersion <= (typeof version === 'string' ? Versions[version] : version)
}
startEncryption (iv) { startEncryption (iv) {
this.encryptionEnabled = true this.encryptionEnabled = true
this.inLog?.('Started encryption', this.sharedSecret, iv) this.inLog('Started encryption', this.sharedSecret, iv)
this.decrypt = cipher.createDecryptor(this, iv) this.decrypt = cipher.createDecryptor(this, iv)
this.encrypt = cipher.createEncryptor(this, iv) this.encrypt = cipher.createEncryptor(this, iv)
} }
@ -70,18 +56,10 @@ class Connection extends EventEmitter {
} }
} }
_processOutbound (name, params) {
if (name === 'item_registry') {
this.updateItemPalette(params.itemstates)
} else if (name === 'start_game' && params.itemstates) {
this.updateItemPalette(params.itemstates)
}
}
write (name, params) { write (name, params) {
this.outLog?.(name, params) this.outLog('sending', name, params)
this._processOutbound(name, params) if (name === 'start_game') this.updateItemPalette(params.itemstates)
const batch = new Framer(this) const batch = new Framer()
const packet = this.serializer.createPacketBuffer({ name, params }) const packet = this.serializer.createPacketBuffer({ name, params })
batch.addEncodedPacket(packet) batch.addEncodedPacket(packet)
@ -93,8 +71,8 @@ class Connection extends EventEmitter {
} }
queue (name, params) { queue (name, params) {
this.outLog?.('Q <- ', name, params) this.outLog('Q <- ', name, params)
this._processOutbound(name, params) if (name === 'start_game') this.updateItemPalette(params.itemstates)
const packet = this.serializer.createPacketBuffer({ name, params }) const packet = this.serializer.createPacketBuffer({ name, params })
if (name === 'level_chunk') { if (name === 'level_chunk') {
// Skip queue, send ASAP // Skip queue, send ASAP
@ -105,25 +83,22 @@ class Connection extends EventEmitter {
this.sendIds.push(name) this.sendIds.push(name)
} }
_tick () {
if (this.sendQ.length) {
const batch = new Framer(this)
batch.addEncodedPackets(this.sendQ)
this.sendQ = []
this.sendIds = []
if (this.encryptionEnabled) {
this.sendEncryptedBatch(batch)
} else {
this.sendDecryptedBatch(batch)
}
}
}
onTick = this._tick.bind(this)
startQueue () { startQueue () {
this.sendQ = [] this.sendQ = []
this.loop = setInterval(this.onTick, this.options.batchingInterval || 20) this.loop = setInterval(() => {
if (this.sendQ.length) {
const batch = new Framer()
this.outLog('<- Batch', this.sendIds)
batch.addEncodedPackets(this.sendQ)
this.sendQ = []
this.sendIds = []
if (this.encryptionEnabled) {
this.sendEncryptedBatch(batch)
} else {
this.sendDecryptedBatch(batch)
}
}
}, 20)
} }
/** /**
@ -131,7 +106,7 @@ class Connection extends EventEmitter {
*/ */
sendBuffer (buffer, immediate = false) { sendBuffer (buffer, immediate = false) {
if (immediate) { if (immediate) {
const batch = new Framer(this) const batch = new Framer()
batch.addEncodedPacket(buffer) batch.addEncodedPacket(buffer)
if (this.encryptionEnabled) { if (this.encryptionEnabled) {
this.sendEncryptedBatch(batch) this.sendEncryptedBatch(batch)
@ -146,7 +121,7 @@ class Connection extends EventEmitter {
sendDecryptedBatch (batch) { sendDecryptedBatch (batch) {
// send to raknet // send to raknet
this.sendMCPE(batch.encode(), true) batch.encode(buf => this.sendMCPE(buf, true))
} }
sendEncryptedBatch (batch) { sendEncryptedBatch (batch) {
@ -165,29 +140,32 @@ class Connection extends EventEmitter {
// These are callbacks called from encryption.js // These are callbacks called from encryption.js
onEncryptedPacket = (buf) => { onEncryptedPacket = (buf) => {
const packet = this.batchHeader ? Buffer.concat([Buffer.from([this.batchHeader]), buf]) : buf this.outLog('Enc buf', buf)
const packet = Buffer.concat([Buffer.from([0xfe]), buf]) // add header
this.sendMCPE(packet) this.sendMCPE(packet)
} }
onDecryptedPacket = (buf) => { onDecryptedPacket = (buf) => {
const packets = Framer.getPackets(buf) const packets = Framer.getPackets(buf)
for (const packet of packets) { for (const packet of packets) {
this.readPacket(packet) this.readPacket(packet)
} }
} }
handle (buffer) { // handle encapsulated handle (buffer) { // handle encapsulated
if (!this.batchHeader || buffer[0] === this.batchHeader) { // wrapper if (buffer[0] === 0xfe) { // wrapper
if (this.encryptionEnabled) { if (this.encryptionEnabled) {
this.decrypt(buffer.slice(1)) this.decrypt(buffer.slice(1))
} else { } else {
const packets = Framer.decode(this, buffer) Framer.decode(buffer, packets => {
for (const packet of packets) { this.inLog('Reading ', packets.length, 'packets')
this.readPacket(packet) for (const packet of packets) {
} this.readPacket(packet)
}
})
} }
} else {
throw Error('Bad packet header ' + buffer[0])
} }
} }
} }

View file

@ -1,40 +1,24 @@
const { Client } = require('./client') const { Client } = require('./client')
const { RakClient } = require('./rak')('raknet-native') const { RakClient } = require('./rak')
const { sleep } = require('./datatypes/util')
const assert = require('assert') const assert = require('assert')
const Options = require('./options')
const advertisement = require('./server/advertisement') const advertisement = require('./server/advertisement')
const auth = require('./client/auth')
/** @param {{ version?: number, host: string, port?: number, connectTimeout?: number, skipPing?: boolean }} options */ /** @param {{ version?: number, host: string, port?: number, connectTimeout?: number }} options */
function createClient (options) { function createClient (options) {
assert(options) assert(options)
const client = new Client({ port: 19132, followPort: !options.realms, ...options, delayedInit: true }) const client = new Client({ port: 19132, ...options })
function onServerInfo () { if (options.skipPing) {
client.on('connect_allowed', () => connect(client)) connect(client)
if (options.skipPing) { } else { // Try to ping
client.init() client.ping().then(data => {
} else { const advert = advertisement.fromServerName(data)
ping(client.options).then(ad => { console.log(`Connecting to server ${advert.motd} (${advert.name}), version ${advert.version}`)
const adVersion = ad.version?.split('.').slice(0, 3).join('.') // Only 3 version units // TODO: update connect version based on ping response
client.options.version = options.version ?? (Options.Versions[adVersion] ? adVersion : Options.CURRENT_VERSION) connect(client)
}, client)
if (ad.portV4 && client.options.followPort) {
client.options.port = ad.portV4
}
client.conLog?.(`Connecting to ${client.options.host}:${client.options.port} ${ad.motd} (${ad.levelName}), version ${ad.version} ${client.options.version !== ad.version ? ` (as ${client.options.version})` : ''}`)
client.init()
}).catch(e => client.emit('error', e))
}
} }
if (options.realms) {
auth.realmAuthenticate(client.options).then(onServerInfo).catch(e => client.emit('error', e))
} else {
onServerInfo()
}
return client return client
} }
@ -53,49 +37,39 @@ function connect (client) {
response_status: 'completed', response_status: 'completed',
resourcepackids: [] resourcepackids: []
}) })
client.queue('request_chunk_radius', { chunk_radius: client.renderDistance || 10 })
}) })
client.queue('client_cache_status', { enabled: false }) client.queue('client_cache_status', { enabled: false })
client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n })
if (client.versionLessThanOrEqualTo('1.20.80')) client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n })
sleep(500).then(() => client.queue('request_chunk_radius', { chunk_radius: client.viewDistance || 10 }))
}) })
if (client.versionLessThanOrEqualTo('1.20.80')) { const KEEPALIVE_INTERVAL = 10 // Send tick sync packets every 10 ticks
const keepAliveInterval = 10 let keepalive
const keepAliveIntervalBig = BigInt(keepAliveInterval) client.tick = 0n
client.once('spawn', () => {
keepalive = setInterval(() => {
// Client fills out the request_time and the server does response_time in its reply.
client.queue('tick_sync', { request_time: client.tick, response_time: 0n })
client.tick += BigInt(KEEPALIVE_INTERVAL)
}, 50 * KEEPALIVE_INTERVAL)
let keepalive client.on('tick_sync', async packet => {
client.tick = 0n client.emit('heartbeat', packet.response_time)
client.tick = packet.response_time
client.once('spawn', () => {
keepalive = setInterval(() => {
// Client fills out the request_time and the server does response_time in its reply.
client.queue('tick_sync', { request_time: client.tick, response_time: 0n })
client.tick += keepAliveIntervalBig
}, 50 * keepAliveInterval)
client.on('tick_sync', async packet => {
client.emit('heartbeat', packet.response_time)
client.tick = packet.response_time
})
}) })
})
client.once('close', () => { client.once('close', () => {
clearInterval(keepalive) clearInterval(keepalive)
}) })
}
} }
async function ping ({ host, port }) { async function ping ({ host, port }) {
const con = new RakClient({ host, port }) const con = new RakClient({ host, port })
const ret = await con.ping()
try { con.close()
return advertisement.fromServerName(await con.ping()) return advertisement.fromServerName(ret)
} finally {
con.close()
}
} }
module.exports = { createClient, ping } module.exports = { createClient, ping }

View file

@ -1,7 +1,7 @@
/* eslint-disable */ /* eslint-disable */
const UUID = require('uuid-1345') const UUID = require('uuid-1345')
const minecraft = require('./minecraft') const minecraft = require('./minecraft')
const [Read, Write, SizeOf] = [{}, {}, {}] const { Read, Write, SizeOf } = require('./varlong')
/** /**
* UUIDs * UUIDs
@ -116,6 +116,74 @@ Read.lnbt = ['native', minecraft.lnbt[0]]
Write.lnbt = ['native', minecraft.lnbt[1]] Write.lnbt = ['native', minecraft.lnbt[1]]
SizeOf.lnbt = ['native', minecraft.lnbt[2]] SizeOf.lnbt = ['native', minecraft.lnbt[2]]
/**
* Bits
*/
Read.bitflags = ['parametrizable', (compiler, { type, flags, shift, big }) => {
let fstr = JSON.stringify(flags)
if (Array.isArray(flags)) {
fstr = '{'
flags.map((v, k) => fstr += `"${v}": ${big ? 1n << BigInt(k) : 1 << k}` + (big ? 'n,' : ','))
fstr += '}'
} else if (shift) {
fstr = '{'
for (const key in flags) fstr += `"${key}": ${1 << flags[key]},`;
fstr += '}'
}
return compiler.wrapCode(`
const { value: _value, size } = ${compiler.callType(type, 'offset')}
const value = { _value }
const flags = ${fstr}
for (const key in flags) {
value[key] = (_value & flags[key]) == flags[key]
}
return { value, size }
`.trim())
}]
Write.bitflags = ['parametrizable', (compiler, { type, flags, shift, big }) => {
let fstr = JSON.stringify(flags)
if (Array.isArray(flags)) {
fstr = '{'
flags.map((v, k) => fstr += `"${v}": ${big ? 1n << BigInt(k) : 1 << k}` + (big ? 'n,' : ','))
fstr += '}'
} else if (shift) {
fstr = '{'
for (const key in flags) fstr += `"${key}": ${1 << flags[key]},`;
fstr += '}'
}
return compiler.wrapCode(`
const flags = ${fstr}
let val = value._value ${big ? '|| 0n' : ''}
for (const key in flags) {
if (value[key]) val |= flags[key]
}
return (ctx.${type})(val, buffer, offset)
`.trim())
}]
SizeOf.bitflags = ['parametrizable', (compiler, { type, flags, shift, big }) => {
let fstr = JSON.stringify(flags)
if (Array.isArray(flags)) {
fstr = '{'
flags.map((v, k) => fstr += `"${v}": ${big ? 1n << BigInt(k) : 1 << k}` + (big ? 'n,' : ','))
fstr += '}'
} else if (shift) {
fstr = '{'
for (const key in flags) fstr += `"${key}": ${1 << flags[key]},`;
fstr += '}'
}
return compiler.wrapCode(`
const flags = ${fstr}
let val = value._value ${big ? '|| 0n' : ''}
for (const key in flags) {
if (value[key]) val |= flags[key]
}
return (ctx.${type})(val)
`.trim())
}]
/** /**
* Command Packet * Command Packet
* - used for determining the size of the following enum * - used for determining the size of the following enum

View file

@ -1,11 +1,14 @@
/* eslint-disable */
const nbt = require('prismarine-nbt') const nbt = require('prismarine-nbt')
const UUID = require('uuid-1345') const UUID = require('uuid-1345')
const protoLE = nbt.protos.little const protoLE = nbt.protos.little
const protoLEV = nbt.protos.littleVarint const protoLEV = nbt.protos.littleVarint
// TODO: deal with this:
const zigzag = require('prismarine-nbt/compiler-zigzag')
function readUUID (buffer, offset) { function readUUID (buffer, offset) {
if (offset + 16 > buffer.length) { throw new Error('Reached end of buffer') } if (offset + 16 > buffer.length) { throw new PartialReadError() }
return { return {
value: UUID.stringify(buffer.slice(offset, 16 + offset)), value: UUID.stringify(buffer.slice(offset, 16 + offset)),
size: 16 size: 16
@ -36,21 +39,17 @@ function sizeOfNbt (value) {
function readNbtLE (buffer, offset) { function readNbtLE (buffer, offset) {
const r = protoLE.read(buffer, offset, 'nbt') const r = protoLE.read(buffer, offset, 'nbt')
// End size is 3 for some reason if (r.value.type === 'end') return { value: r.value, size: 0 }
if (r.value.type === 'end') return { value: r.value, size: 1 }
return r return r
} }
function writeNbtLE (value, buffer, offset) { function writeNbtLE (value, buffer, offset) {
if (value.type === 'end') { if (value.type === 'end') return offset
buffer.writeInt8(0, offset)
return offset + 1
}
return protoLE.write(value, buffer, offset, 'nbt') return protoLE.write(value, buffer, offset, 'nbt')
} }
function sizeOfNbtLE (value) { function sizeOfNbtLE (value) {
if (value.type === 'end') return 1 if (value.type === 'end') return 0
return protoLE.sizeOf(value, 'nbt') return protoLE.sizeOf(value, 'nbt')
} }
@ -62,7 +61,7 @@ function readEntityMetadata (buffer, offset, _ref) {
const metadata = [] const metadata = []
let item let item
while (true) { while (true) {
if (offset + 1 > buffer.length) throw new Error('Reached end of buffer') if (offset + 1 > buffer.length) throw new PartialReadError()
item = buffer.readUInt8(cursor) item = buffer.readUInt8(cursor)
if (item === endVal) { if (item === endVal) {
return { return {
@ -156,5 +155,7 @@ module.exports = {
lnbt: [readNbtLE, writeNbtLE, sizeOfNbtLE], lnbt: [readNbtLE, writeNbtLE, sizeOfNbtLE],
entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata], entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata],
ipAddress: [readIpAddress, writeIpAddress, 4], ipAddress: [readIpAddress, writeIpAddress, 4],
endOfArray: [readEndOfArray, writeEndOfArray, sizeOfEndOfArray] endOfArray: [readEndOfArray, writeEndOfArray, sizeOfEndOfArray],
zigzag32: zigzag.zigzag32,
zigzag64: zigzag.zigzag64
} }

View file

@ -23,11 +23,11 @@ function sleep (ms) {
async function waitFor (cb, withTimeout, onTimeout) { async function waitFor (cb, withTimeout, onTimeout) {
let t let t
const ret = await Promise.race([ const ret = await Promise.race([
new Promise((resolve, reject) => cb(resolve, reject)), new Promise(resolve => cb(resolve)),
new Promise(resolve => { t = setTimeout(() => resolve('timeout'), withTimeout) }) new Promise(resolve => { t = setTimeout(() => resolve('timeout'), withTimeout) })
]) ])
clearTimeout(t) clearTimeout(t)
if (ret === 'timeout') await onTimeout() if (ret === 'timeout') onTimeout()
return ret return ret
} }
@ -43,6 +43,4 @@ function nextUUID () {
return uuidFrom(Date.now().toString()) return uuidFrom(Date.now().toString())
} }
const isDebug = process.env.DEBUG?.includes('minecraft-protocol') module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom, nextUUID }
module.exports = { getFiles, sleep, waitFor, serialize, uuidFrom, nextUUID, isDebug }

63
src/datatypes/varlong.js Normal file
View file

@ -0,0 +1,63 @@
function sizeOfVarLong (value) {
if (typeof value.valueOf() === 'object') {
value = (BigInt(value[0]) << 32n) | BigInt(value[1])
} else if (typeof value !== 'bigint') value = BigInt(value)
let cursor = 0
while (value > 127n) {
value >>= 7n
cursor++
}
return cursor + 1
}
/**
* Reads a 64-bit VarInt as a BigInt
*/
function readVarLong (buffer, offset) {
let result = BigInt(0)
let shift = 0n
let cursor = offset
let size = 0
while (true) {
if (cursor + 1 > buffer.length) { throw new Error('unexpected buffer end') }
const b = buffer.readUInt8(cursor)
result |= (BigInt(b) & 0x7fn) << shift // Add the bits to our number, except MSB
cursor++
if (!(b & 0x80)) { // If the MSB is not set, we return the number
size = cursor - offset
break
}
shift += 7n // we only have 7 bits, MSB being the return-trigger
if (shift > 63n) throw new Error(`varint is too big: ${shift}`)
}
return { value: result, size }
}
/**
* Writes a zigzag encoded 64-bit VarInt as a BigInt
*/
function writeVarLong (value, buffer, offset) {
// if an array, turn it into a BigInt
if (typeof value.valueOf() === 'object') {
value = BigInt.asIntN(64, (BigInt(value[0]) << 32n)) | BigInt(value[1])
} else if (typeof value !== 'bigint') value = BigInt(value)
let cursor = 0
while (value > 127n) { // keep writing in 7 bit slices
const num = Number(value & 0xFFn)
buffer.writeUInt8(num | 0x80, offset + cursor)
cursor++
value >>= 7n
}
buffer.writeUInt8(Number(value), offset + cursor)
return offset + cursor + 1
}
module.exports = {
Read: { varint64: ['native', readVarLong] },
Write: { varint64: ['native', writeVarLong] },
SizeOf: { varint64: ['native', sizeOfVarLong] }
}

View file

@ -1,3 +1,3 @@
module.exports = { module.exports = {
PUBLIC_KEY: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAECRXueJeTDqNRRgJi/vlRufByu/2G0i2Ebt6YMar5QX/R0DIIyrJMcUpruK4QveTfJSTp3Shlq4Gk34cD/4GUWwkv0DVuzeuB+tXija7HBxii03NHDbPAD0AKnLr2wdAp' PUBLIC_KEY: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8ELkixyLcwlZryUQcu1TvPOmI2B7vX83ndnWRUaXm74wFfa5f/lwQNTfrLVHa2PmenpGI6JhIMUJaWZrjmMj90NoKNFSNBuKdm8rYiXsfaz3K36x/1U26HpG0ZxK/V1V'
} }

View file

@ -13,6 +13,7 @@ function KeyExchange (client, server, options) {
client.ecdhKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: curve }) client.ecdhKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: curve })
client.publicKeyDER = client.ecdhKeyPair.publicKey.export(der) client.publicKeyDER = client.ecdhKeyPair.publicKey.export(der)
client.privateKeyPEM = client.ecdhKeyPair.privateKey.export(pem) client.privateKeyPEM = client.ecdhKeyPair.privateKey.export(pem)
console.log(client.publicKeyPEM)
client.clientX509 = client.publicKeyDER.toString('base64') client.clientX509 = client.publicKeyDER.toString('base64')
function startClientboundEncryption (publicKey) { function startClientboundEncryption (publicKey) {
@ -52,7 +53,7 @@ function KeyExchange (client, server, options) {
debug('[encrypt] Starting serverbound encryption', token) debug('[encrypt] Starting serverbound encryption', token)
const jwt = token?.token const jwt = token?.token
if (!jwt) { if (!jwt) {
throw Error('Server did not return a valid JWT, cannot start encryption') throw Error('Server did not return a valid JWT, cannot start encryption!')
} }
// No verification here, not needed // No verification here, not needed

View file

@ -1,10 +1,15 @@
const fs = require('fs')
const JWT = require('jsonwebtoken') const JWT = require('jsonwebtoken')
const DataProvider = require('../../data/provider')
const { nextUUID } = require('../datatypes/util') const { nextUUID } = require('../datatypes/util')
const { PUBLIC_KEY } = require('./constants') const { PUBLIC_KEY } = require('./constants')
const algorithm = 'ES384' const algorithm = 'ES384'
module.exports = (client, server, options) => { module.exports = (client, server, options) => {
const skinData = require('minecraft-data')('bedrock_' + options.version).defaultSkin const dp = DataProvider(options.protocolVersion)
const skinTex = fs.readFileSync(dp.getPath('steveSkin.bin')).toString('base64')
const skinGeom = fs.readFileSync(dp.getPath('steveGeometry.json')).toString('base64')
const skinData = JSON.parse(fs.readFileSync(dp.getPath('steve.json'), 'utf-8'))
client.createClientChain = (mojangKey, offline) => { client.createClientChain = (mojangKey, offline) => {
const privateKey = client.ecdhKeyPair.privateKey const privateKey = client.ecdhKeyPair.privateKey
@ -15,8 +20,7 @@ module.exports = (client, server, options) => {
extraData: { extraData: {
displayName: client.username, displayName: client.username,
identity: client.profile.uuid, identity: client.profile.uuid,
titleId: '89692877', titleId: '89692877'
XUID: '0'
}, },
certificateAuthority: true, certificateAuthority: true,
identityPublicKey: client.clientX509 identityPublicKey: client.clientX509
@ -46,30 +50,22 @@ module.exports = (client, server, options) => {
GameVersion: options.version || '1.16.201', GameVersion: options.version || '1.16.201',
GuiScale: -1, GuiScale: -1,
LanguageCode: 'en_GB', // TODO locale LanguageCode: 'en_GB', // TODO locale
GraphicsMode: 1, // 1:simple, 2:fancy, 3:advanced, 4:ray_traced
PlatformOfflineId: '', PlatformOfflineId: '',
PlatformOnlineId: '', // chat PlatformOnlineId: '', // chat
// PlayFabID is the PlayFab ID produced for the skin. PlayFab is the company that hosts the Marketplace, // PlayFabID is the PlayFab ID produced for the skin. PlayFab is the company that hosts the Marketplace,
// skins and other related features from the game. This ID is the ID of the skin used to store the skin // skins and other related features from the game. This ID is the ID of the skin used to store the skin
// inside of PlayFab.The playfab ID is always lowercased. // inside of PlayFab.
PlayFabId: nextUUID().replace(/-/g, '').slice(0, 16).toLowerCase(), // 1.16.210 PlayFabId: nextUUID().replace(/-/g, '').slice(0, 16), // 1.16.210
SelfSignedId: nextUUID(), SelfSignedId: nextUUID(),
ServerAddress: `${options.host}:${options.port}`, ServerAddress: `${options.host}:${options.port}`,
SkinData: skinTex,
SkinGeometryData: skinGeom,
ThirdPartyName: client.profile.name, // Gamertag ThirdPartyName: client.profile.name,
ThirdPartyNameOnly: client.versionGreaterThanOrEqualTo('1.21.90') ? undefined : false, ThirdPartyNameOnly: false,
UIProfile: 0, UIProfile: 0
IsEditorMode: false,
TrustedSkin: client.versionGreaterThanOrEqualTo('1.19.20') ? false : undefined,
OverrideSkin: client.versionGreaterThanOrEqualTo('1.19.62') ? false : undefined,
CompatibleWithClientSideChunkGen: client.versionGreaterThanOrEqualTo('1.19.80') ? false : undefined,
MaxViewDistance: client.versionGreaterThanOrEqualTo('1.21.42') ? 0 : undefined,
MemoryTier: client.versionGreaterThanOrEqualTo('1.21.42') ? 0 : undefined,
PlatformType: client.versionGreaterThanOrEqualTo('1.21.42') ? 0 : undefined
} }
const customPayload = options.skinData || {} const customPayload = options.skinData || {}
payload = { ...payload, ...customPayload } payload = { ...payload, ...customPayload }

View file

@ -24,6 +24,7 @@ module.exports = (client, server, options) => {
for (const token of chain) { 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 // Check if signed by Mojang key
const x5u = getX5U(token) const x5u = getX5U(token)
@ -36,6 +37,7 @@ module.exports = (client, server, options) => {
finalKey = decoded.identityPublicKey || finalKey // non pem finalKey = decoded.identityPublicKey || finalKey // non pem
data = { ...data, ...decoded } data = { ...data, ...decoded }
} }
// console.log('Result', data)
if (!didVerify && !options.offline) { if (!didVerify && !options.offline) {
client.disconnect('disconnectionScreen.notAuthenticated') client.disconnect('disconnectionScreen.notAuthenticated')

View file

@ -1,18 +1,16 @@
const mcData = require('minecraft-data')
// Minimum supported version (< will be kicked) // Minimum supported version (< will be kicked)
const MIN_VERSION = '1.16.201' const MIN_VERSION = '1.16.201'
// Currently supported verson. Note, clients with newer versions can still connect as long as data is in minecraft-data // Currently supported verson
const CURRENT_VERSION = '1.21.111' const CURRENT_VERSION = '1.16.220'
const Versions = Object.fromEntries(mcData.versions.bedrock.filter(e => e.releaseType === 'release').map(e => [e.minecraftVersion, e.version])) const Versions = {
'1.16.220': 431,
// Skip some low priority versions (middle major) on Github Actions to allow faster CI '1.16.210': 428,
const skippedVersionsOnGithubCI = ['1.16.210', '1.17.10', '1.17.30', '1.18.11', '1.19.10', '1.19.20', '1.19.30', '1.19.40', '1.19.50', '1.19.60', '1.19.63', '1.19.70', '1.20.10', '1.20.15', '1.20.30', '1.20.40', '1.20.50', '1.20.61', '1.20.71', '1.21.2', '1.21.20', '1.21.30', '1.21.42', '1.21.50', '1.21.60', '1.21.70', '1.21.80', '1.21.90'] '1.16.201': 422
const testedVersions = process.env.CI ? Object.keys(Versions).filter(v => !skippedVersionsOnGithubCI.includes(v)) : Object.keys(Versions) }
const defaultOptions = { const defaultOptions = {
// https://minecraft.wiki/w/Protocol_version#Bedrock_Edition_2 // https://minecraft.gamepedia.com/Protocol_version#Bedrock_Edition_2
version: CURRENT_VERSION, version: CURRENT_VERSION,
// client: If we should send SetPlayerInitialized to the server after getting play_status spawn. // client: If we should send SetPlayerInitialized to the server after getting play_status spawn.
// if this is disabled, no 'spawn' event will be emitted, you should manually set // if this is disabled, no 'spawn' event will be emitted, you should manually set
@ -21,32 +19,7 @@ const defaultOptions = {
// If true, do not authenticate with Xbox Live // If true, do not authenticate with Xbox Live
offline: false, offline: false,
// Milliseconds to wait before aborting connection attempt // Milliseconds to wait before aborting connection attempt
connectTimeout: 9000, connectTimeout: 9000
// Specifies the raknet implementation to use
raknetBackend: 'raknet-native',
// If using JS implementation of RakNet, should we use workers? (This only affects the client)
useRaknetWorkers: true,
// server: What compression algorithm to use by default, either `none`, `deflate` or `snappy`
compressionAlgorithm: 'deflate',
// server and client: On Deflate, what compression level to use, between 1 and 9
compressionLevel: 7,
// server: If true, only compress if a payload is larger than compressionThreshold
compressionThreshold: 512
} }
function validateOptions (options) { module.exports = { defaultOptions, MIN_VERSION, CURRENT_VERSION, Versions }
if (!Versions[options.version]) {
console.warn('Supported versions', Versions)
throw Error(`Unsupported version ${options.version}`)
}
options.protocolVersion = Versions[options.version]
if (options.protocolVersion < MIN_VERSION) {
throw new Error(`Protocol version < ${MIN_VERSION} : ${options.protocolVersion}, too old`)
}
if (options.useNativeRaknet === true) options.raknetBackend = 'raknet-native'
if (options.useNativeRaknet === false) options.raknetBackend = 'jsp-raknet'
}
module.exports = { defaultOptions, MIN_VERSION, CURRENT_VERSION, Versions, validateOptions, testedVersions }

View file

@ -1,48 +1,23 @@
const { EventEmitter } = require('events') const { EventEmitter } = require('events')
const ConnWorker = require('./rakWorker') const ConnWorker = require('./rakWorker')
const { waitFor } = require('./datatypes/util') const { waitFor } = require('./datatypes/util')
// TODO: better way to switch, via an option
let Client, Server, PacketPriority, EncapsulatedPacket, PacketReliability, Reliability try {
class RakTimeout extends Error {}; var { Client, Server, PacketPriority, PacketReliability } = require('raknet-native') // eslint-disable-line
} catch (e) {
function setBackend (backend) { var { Client, Server, EncapsulatedPacket, Reliability } = require('jsp-raknet') // eslint-disable-line
// We have to explicitly require the backend for bundlers console.debug('[raknet] native not found, using js', e)
switch (backend) {
case 'raknet-node':
({ Client, Server, PacketPriority, PacketReliability } = require('raknet-node'))
return { RakServer: RakNativeServer, RakClient: RakNativeClient, RakTimeout }
case 'raknet-native':
({ Client, Server, PacketPriority, PacketReliability } = require('raknet-native'))
return { RakServer: RakNativeServer, RakClient: RakNativeClient, RakTimeout }
case 'jsp-raknet':
({ Client, Server, EncapsulatedPacket, Reliability } = require('jsp-raknet'))
return { RakServer: RakJsServer, RakClient: RakJsClient, RakTimeout }
}
}
module.exports = (backend) => {
if (backend) {
return setBackend(backend)
} else {
try {
return setBackend('raknet-native')
} catch (e) {
console.debug(`[raknet] ${backend} library not found, defaulting to jsp-raknet. Correct the "raknetBackend" option to avoid this error.`, e)
return setBackend('jsp-raknet')
}
}
} }
class RakNativeClient extends EventEmitter { class RakNativeClient extends EventEmitter {
constructor (options, client) { constructor (options) {
super() super()
this.connected = false this.connected = false
this.onConnected = () => { } this.onConnected = () => { }
this.onCloseConnection = () => { } this.onCloseConnection = () => { }
this.onEncapsulated = () => { } this.onEncapsulated = () => { }
const protocolVersion = client?.versionGreaterThanOrEqualTo('1.19.30') ? 11 : 10 this.raknet = new Client(options.host, options.port, { protocolVersion: 10 })
this.raknet = new Client(options.host, options.port, { protocolVersion })
this.raknet.on('encapsulated', ({ buffer, address }) => { this.raknet.on('encapsulated', ({ buffer, address }) => {
if (this.connected) { // Discard packets that are queued to be sent to us after close if (this.connected) { // Discard packets that are queued to be sent to us after close
this.onEncapsulated(buffer, address) this.onEncapsulated(buffer, address)
@ -68,12 +43,7 @@ class RakNativeClient extends EventEmitter {
done(ret.extra.toString()) done(ret.extra.toString())
} }
}) })
}, timeout, () => { }, timeout, () => { throw new Error('Ping timed out') })
if ('REPLIT_ENVIRONMENT' in process.env) {
console.warn('A Replit environment was detected. Replit may not support the necessary outbound UDP connections required to connect to a Minecraft server. Please see https://github.com/PrismarineJS/bedrock-protocol/blob/master/docs/FAQ.md for more information.')
}
throw new RakTimeout('Ping timed out')
})
} }
connect () { connect () {
@ -102,10 +72,9 @@ class RakNativeServer extends EventEmitter {
this.onEncapsulated = () => { } this.onEncapsulated = () => { }
this.raknet = new Server(options.host, options.port, { this.raknet = new Server(options.host, options.port, {
maxConnections: options.maxPlayers || 3, maxConnections: options.maxPlayers || 3,
protocolVersion: server.versionLessThan('1.19.30') ? 10 : 11, protocolVersion: 10,
message: server.getAdvertisement().toBuffer() message: server.getAdvertisement().toBuffer()
}) })
this.onClose = () => {}
this.updateAdvertisement = () => { this.updateAdvertisement = () => {
this.raknet.setOfflineMessage(server.getAdvertisement().toBuffer()) this.raknet.setOfflineMessage(server.getAdvertisement().toBuffer())
@ -126,8 +95,6 @@ class RakNativeServer extends EventEmitter {
this.raknet.on('encapsulated', ({ buffer, address }) => { this.raknet.on('encapsulated', ({ buffer, address }) => {
this.onEncapsulated(buffer, address) this.onEncapsulated(buffer, address)
}) })
this.raknet.on('close', (reason) => this.onClose(reason))
} }
listen () { listen () {
@ -144,7 +111,6 @@ class RakJsClient extends EventEmitter {
super() super()
this.options = options this.options = options
this.onConnected = () => { } this.onConnected = () => { }
this.onCloseConnection = () => { }
this.onEncapsulated = () => { } this.onEncapsulated = () => { }
if (options.useWorkers) { if (options.useWorkers) {
this.connect = this.workerConnect this.connect = this.workerConnect
@ -152,10 +118,9 @@ class RakJsClient extends EventEmitter {
this.sendReliable = this.workerSendReliable this.sendReliable = this.workerSendReliable
} else { } else {
this.connect = this.plainConnect this.connect = this.plainConnect
this.close = reason => this.raknet.close(reason) this.close = this.plainClose
this.sendReliable = this.plainSendReliable this.sendReliable = this.plainSendReliable
} }
this.pongCb = null
} }
workerConnect (host = this.options.host, port = this.options.port) { workerConnect (host = this.options.host, port = this.options.port) {
@ -172,12 +137,6 @@ class RakJsClient extends EventEmitter {
this.onEncapsulated(ecapsulated, address.hash) this.onEncapsulated(ecapsulated, address.hash)
break break
} }
case 'pong':
this.pongCb?.(evt.args)
break
case 'disconnect':
this.onCloseConnection()
break
} }
}) })
} }
@ -191,8 +150,7 @@ class RakJsClient extends EventEmitter {
}) })
this.raknet.on('connected', this.onConnected) this.raknet.on('connected', this.onConnected)
this.raknet.on('encapsulated', (encapsulated, addr) => this.onEncapsulated(encapsulated, addr.hash)) this.raknet.on('encapsulated', (encapsulated, addr) => this.onEncapsulated(encapsulated.buffer, addr.hash))
this.raknet.on('disconnect', (reason) => this.onCloseConnection(reason))
} }
workerSendReliable (buffer, immediate) { workerSendReliable (buffer, immediate) {
@ -203,25 +161,16 @@ class RakJsClient extends EventEmitter {
const sendPacket = new EncapsulatedPacket() const sendPacket = new EncapsulatedPacket()
sendPacket.reliability = Reliability.ReliableOrdered sendPacket.reliability = Reliability.ReliableOrdered
sendPacket.buffer = buffer sendPacket.buffer = buffer
this.raknet.connection.addEncapsulatedToQueue(sendPacket) this.connection.addEncapsulatedToQueue(sendPacket)
if (immediate) this.raknet.connection.sendQueue() if (immediate) this.connection.sendQueue()
} }
async ping (timeout = 1000) { plainClose (reason) {
if (this.worker) { this.raknet.close(reason)
this.worker.postMessage({ type: 'ping' }) }
return waitFor(res => {
this.pongCb = data => res(data) ping () {
}, timeout, () => { throw new RakTimeout('Ping timed out') }) // TODO
} else {
if (!this.raknet) this.raknet = new Client(this.options.host, this.options.port)
return waitFor(res => {
this.raknet.ping(data => {
this.raknet.close()
res(data)
})
}, timeout, () => { throw new RakTimeout('Ping timed out') })
}
} }
} }
@ -233,9 +182,8 @@ class RakJsServer extends EventEmitter {
this.onOpenConnection = () => { } this.onOpenConnection = () => { }
this.onCloseConnection = () => { } this.onCloseConnection = () => { }
this.onEncapsulated = (packet, address) => server.onEncapsulated(packet.buffer, address) this.onEncapsulated = (packet, address) => server.onEncapsulated(packet.buffer, address)
this.onClose = () => {}
this.updateAdvertisement = () => { this.updateAdvertisement = () => {
this.raknet.setPongAdvertisement(server.getAdvertisement()) // TODO
} }
if (options.useWorkers) { if (options.useWorkers) {
throw Error('nyi') throw Error('nyi')
@ -258,13 +206,10 @@ class RakJsServer extends EventEmitter {
}) })
this.raknet.on('closeConnection', this.onCloseConnection) this.raknet.on('closeConnection', this.onCloseConnection)
this.raknet.on('encapsulated', this.onEncapsulated) this.raknet.on('encapsulated', this.onEncapsulated)
this.raknet.on('close', this.onClose)
}
close () {
// Allow some time for the final packets to come in/out
setTimeout(() => {
this.raknet.close()
}, 40)
} }
} }
module.exports = {
RakClient: PacketPriority ? RakNativeClient : RakJsClient,
RakServer: PacketPriority ? RakNativeServer : RakJsServer
}

View file

@ -34,16 +34,13 @@ function main () {
}) })
raknet.on('encapsulated', (...args) => { raknet.on('encapsulated', (...args) => {
parentPort.postMessage({ type: 'encapsulated', args }) setTimeout(() => {
}) parentPort.postMessage({ type: 'encapsulated', args })
}, 100)
raknet.on('disconnect', (reason) => {
debug('[worker] disconnected!')
parentPort.postMessage({ type: 'disconnect', reason })
}) })
raknet.on('raw', (buffer, inetAddr) => { raknet.on('raw', (buffer, inetAddr) => {
debug('Raw packet', buffer, inetAddr) console.log('Raw packet', buffer, inetAddr)
}) })
} else if (evt.type === 'queueEncapsulated') { } else if (evt.type === 'queueEncapsulated') {
const sendPacket = new EncapsulatedPacket() const sendPacket = new EncapsulatedPacket()
@ -56,11 +53,6 @@ function main () {
} }
} else if (evt.type === 'close') { } else if (evt.type === 'close') {
raknet.close() raknet.close()
process.exit(0)
} else if (evt.type === 'ping') {
raknet.ping((args) => {
parentPort.postMessage({ type: 'pong', args })
})
} }
}) })
} }

View file

@ -1,10 +1,9 @@
const { Client } = require('./client') const { Client } = require('./client')
const { Server } = require('./server') const { Server } = require('./server')
const { Player } = require('./serverPlayer') const { Player } = require('./serverPlayer')
const { realmAuthenticate } = require('./client/auth')
const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol') const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol')
const debugging = false // Do re-encoding tests const debugging = true // Do re-encoding tests
class RelayPlayer extends Player { class RelayPlayer extends Player {
constructor (server, conn) { constructor (server, conn) {
@ -32,66 +31,51 @@ class RelayPlayer extends Player {
this.outLog = this.downOutLog this.outLog = this.downOutLog
this.inLog = this.downInLog this.inLog = this.downInLog
this.chunkSendCache = [] this.chunkSendCache = []
this.sentStartGame = false
this.respawnPacket = [] this.respawnPacket = []
} }
// Called when we get a packet from backend server (Backend -> PROXY -> Client) // Called when we get a packet from backend server (Backend -> PROXY -> Client)
readUpstream (packet) { readUpstream (packet) {
if (!this.startRelaying) { if (!this.startRelaying) {
this.upInLog('Client not ready, queueing packet until join')
this.downQ.push(packet) this.downQ.push(packet)
return return
} }
let des this.upInLog('->', packet)
try { const des = this.server.deserializer.parsePacketBuffer(packet)
des = this.server.deserializer.parsePacketBuffer(packet)
} catch (e) {
this.server.deserializer.dumpFailedBuffer(packet, this.connection.address)
console.error(this.connection.address, e)
if (!this.options.omitParseErrors) {
this.disconnect('Server packet parse error')
}
return
}
const name = des.data.name const name = des.data.name
const params = des.data.params const params = des.data.params
this.upInLog('->', name, params) if (name === 'play_status' && params.status === 'login_success') return // We already sent this, this needs to be sent ASAP or client will disconnect
if (name === 'play_status' && params.status === 'login_success') return // Already sent this, this needs to be sent ASAP or client will disconnect
if (debugging) { // some packet encode/decode testing stuff if (debugging) { // some packet encode/decode testing stuff
this.server.deserializer.verify(des, this.server.serializer) this.server.deserializer.verify(des, this.server.serializer)
} }
this.emit('clientbound', des.data, des) this.emit('clientbound', des.data)
if (!des.canceled) { // If we're sending a chunk, but player isn't yet initialized, wait until it is.
if (name === 'start_game') { // This is wrong and should not be an issue to send chunks before the client
setTimeout(() => { // is in the world; need to investigate further, but for now it's fine.
this.sentStartGame = true if (this.status !== 3) {
}, 500) if (name === 'level_chunk') {
} else if (name === 'level_chunk' && !this.sentStartGame) { this.chunkSendCache.push([name, params])
this.chunkSendCache.push(params)
return return
} }
if (name === 'respawn') this.respawnPacket.push([name, params])
this.queue(name, params) } else if (this.status === 3 && this.chunkSendCache.length) {
} for (const chunk of this.chunkSendCache) {
this.queue(...chunk)
if (this.chunkSendCache.length > 0 && this.sentStartGame) { }
for (const entry of this.chunkSendCache) { for (const rp of this.respawnPacket) {
this.queue('level_chunk', entry) this.queue(...rp)
} }
this.chunkSendCache = [] this.chunkSendCache = []
this.respawnPacket = []
} }
this.queue(name, params)
} }
// Send queued packets to the connected client // Send queued packets to the connected client
flushDownQueue () { flushDownQueue () {
this.downOutLog('Flushing downstream queue')
for (const packet of this.downQ) { for (const packet of this.downQ) {
const des = this.server.deserializer.parsePacketBuffer(packet) const des = this.server.deserializer.parsePacketBuffer(packet)
this.write(des.data.name, des.data.params) this.write(des.data.name, des.data.params)
@ -101,11 +85,10 @@ class RelayPlayer extends Player {
// Send queued packets to the backend upstream server from the client // Send queued packets to the backend upstream server from the client
flushUpQueue () { flushUpQueue () {
this.upOutLog('Flushing upstream queue')
for (const e of this.upQ) { // Send the queue for (const e of this.upQ) { // Send the queue
const des = this.server.deserializer.parsePacketBuffer(e) const des = this.server.deserializer.parsePacketBuffer(e)
if (des.data.name === 'client_cache_status') { if (des.data.name === 'client_cache_status') { // Currently broken, force off the chunk cache
// Currently not working, force off the chunk cache this.upstream.write('client_cache_status', { enabled: false })
} else { } else {
this.upstream.write(des.data.name, des.data.params) this.upstream.write(des.data.name, des.data.params)
} }
@ -119,8 +102,7 @@ class RelayPlayer extends Player {
if (this.startRelaying) { if (this.startRelaying) {
// Upstream is still connecting/handshaking // Upstream is still connecting/handshaking
if (!this.upstream) { if (!this.upstream) {
const des = this.server.deserializer.parsePacketBuffer(packet) this.downInLog('Got downstream connected packet but upstream is not connected yet, added to q', this.upQ.length)
this.downInLog('Got downstream connected packet but upstream is not connected yet, added to q', des)
this.upQ.push(packet) // Put into a queue this.upQ.push(packet) // Put into a queue
return return
} }
@ -136,17 +118,16 @@ class RelayPlayer extends Player {
this.server.deserializer.verify(des, this.server.serializer) this.server.deserializer.verify(des, this.server.serializer)
} }
this.emit('serverbound', des.data, des) this.emit('serverbound', des.data)
if (des.canceled) return
switch (des.data.name) { switch (des.data.name) {
case 'client_cache_status': case 'client_cache_status':
// Force the chunk cache off. // Force the chunk cache off.
this.upstream.queue('client_cache_status', { enabled: this.enableChunkCaching }) this.upstream.queue('client_cache_status', { enabled: false })
break break
case 'set_local_player_as_initialized': case 'set_local_player_as_initialized':
this.status = 3 this.status = 3
// falls through break
default: default:
// Emit the packet as-is back to the upstream server // Emit the packet as-is back to the upstream server
this.downInLog('Relaying', des.data) this.downInLog('Relaying', des.data)
@ -156,11 +137,6 @@ class RelayPlayer extends Player {
super.readPacket(packet) super.readPacket(packet)
} }
} }
close (reason) {
this.upstream?.close(reason)
super.close(reason)
}
} }
class Relay extends Server { class Relay extends Server {
@ -171,62 +147,31 @@ class Relay extends Server {
constructor (options) { constructor (options) {
super(options) super(options)
this.RelayPlayer = options.relayPlayer || RelayPlayer this.RelayPlayer = options.relayPlayer || RelayPlayer
this.forceSingle = options.forceSingle this.forceSingle = true
this.upstreams = new Map() this.upstreams = new Map()
this.conLog = debug this.conLog = debug
this.enableChunkCaching = options.enableChunkCaching
} }
// Called after a new player joins our proxy. We first create a new Client to connect to openUpstreamConnection (ds, clientAddr) {
// the remote server. Then we listen to some events and proxy them over. The queue and const client = new Client({
// flushing logic is more of an accessory to make sure the server or client recieves offline: this.options.offline,
// a packet, no matter what state it's in. For example, if the client wants to send a username: this.options.offline ? ds.profile.name : null,
// packet to the server but it's not connected, it will add to the queue and send as soon
// as a connection with the server is established.
async openUpstreamConnection (ds, clientAddr) {
const options = {
authTitle: this.options.authTitle,
flow: this.options.flow,
deviceType: this.options.deviceType,
offline: this.options.destination.offline ?? this.options.offline,
username: this.options.offline ? ds.profile.name : ds.profile.xuid,
version: this.options.version, version: this.options.version,
realms: this.options.destination.realms,
host: this.options.destination.host, host: this.options.destination.host,
port: this.options.destination.port, port: this.options.destination.port,
batchingInterval: this.options.batchingInterval,
onMsaCode: (code) => {
if (this.options.onMsaCode) {
this.options.onMsaCode(code, ds)
} else {
ds.disconnect("It's your first time joining. Please sign in and reconnect to join this server:\n\n" + code.message)
}
},
profilesFolder: this.options.profilesFolder,
backend: this.options.backend,
autoInitPlayer: false autoInitPlayer: false
}
if (this.options.destination.realms) {
await realmAuthenticate(options)
}
const client = new Client(options)
// Set the login payload unless `noLoginForward` option
if (!client.noLoginForward) client.options.skinData = ds.skinData
client.ping().then(pongData => {
client.connect()
}).catch(err => {
this.emit('error', err)
}) })
this.conLog('Connecting to', options.host, options.port) // Set the login payload unless `noLoginForward` option
if (!client.noLoginForward) client.skinData = ds.skinData
client.connect()
this.conLog('Connecting to', this.options.destination.host, this.options.destination.port)
client.outLog = ds.upOutLog client.outLog = ds.upOutLog
client.inLog = ds.upInLog client.inLog = ds.upInLog
client.once('join', () => { client.once('join', () => { // Intercept once handshaking done
// Tell the server to disable chunk cache for this connection as a client. // Tell the server to disable chunk cache for this connection as a client.
// Wait a bit for the server to ack and process, the continue with proxying // Wait a bit for the server to ack and process, the continue with proxying
// otherwise the player can get stuck in an empty world. // otherwise the player can get stuck in an empty world.
client.write('client_cache_status', { enabled: this.enableChunkCaching }) client.write('client_cache_status', { enabled: false })
ds.upstream = client ds.upstream = client
ds.flushUpQueue() ds.flushUpQueue()
this.conLog('Connected to upstream server') this.conLog('Connected to upstream server')
@ -234,20 +179,9 @@ class Relay extends Server {
this.emit('join', /* client connected to proxy */ ds, /* backend server */ client) this.emit('join', /* client connected to proxy */ ds, /* backend server */ client)
}) })
client.on('error', (err) => {
ds.disconnect('Server error: ' + err.message)
debug(clientAddr, 'was disconnected because of error', err)
this.upstreams.delete(clientAddr.hash)
})
client.on('close', (reason) => {
ds.disconnect('Backend server closed connection')
this.upstreams.delete(clientAddr.hash)
})
this.upstreams.set(clientAddr.hash, client) this.upstreams.set(clientAddr.hash, client)
} }
// Close a connection to a remote backend server.
closeUpstreamConnection (clientAddr) { closeUpstreamConnection (clientAddr) {
const up = this.upstreams.get(clientAddr.hash) const up = this.upstreams.get(clientAddr.hash)
if (!up) throw Error(`unable to close non-open connection ${clientAddr.hash}`) if (!up) throw Error(`unable to close non-open connection ${clientAddr.hash}`)
@ -256,14 +190,11 @@ class Relay extends Server {
this.conLog('closed upstream connection', clientAddr) this.conLog('closed upstream connection', clientAddr)
} }
// Called when a new player connects to our proxy server. Once the player has authenticated,
// we can open an upstream connection to the backend server.
onOpenConnection = (conn) => { onOpenConnection = (conn) => {
if (this.forceSingle && this.clientCount > 0) { if (this.forceSingle && this.clientCount > 0) {
this.conLog('dropping connection as single client relay', conn) this.conLog('dropping connection as single client relay', conn)
conn.close() conn.close()
} else { } else {
this.clientCount++
const player = new this.RelayPlayer(this, conn) const player = new this.RelayPlayer(this, conn)
this.conLog('New connection from', conn.address) this.conLog('New connection from', conn.address)
this.clients[conn.address] = player this.clients[conn.address] = player
@ -271,15 +202,9 @@ class Relay extends Server {
player.on('login', () => { player.on('login', () => {
this.openUpstreamConnection(player, conn.address) this.openUpstreamConnection(player, conn.address)
}) })
player.on('close', (reason) => {
this.conLog('player disconnected', conn.address, reason)
this.clientCount--
delete this.clients[conn.address]
})
} }
} }
// When our server is closed, make sure to kick all of the connected clients and run emitters.
close (...a) { close (...a) {
for (const [, v] of this.upstreams) { for (const [, v] of this.upstreams) {
v.close(...a) v.close(...a)

View file

@ -1,6 +1,7 @@
const { EventEmitter } = require('events') const { EventEmitter } = require('events')
const { createDeserializer, createSerializer } = require('./transforms/serializer') const { createDeserializer, createSerializer } = require('./transforms/serializer')
const { Player } = require('./serverPlayer') const { Player } = require('./serverPlayer')
const { RakServer } = require('./rak')
const { sleep } = require('./datatypes/util') const { sleep } = require('./datatypes/util')
const { ServerAdvertisement } = require('./server/advertisement') const { ServerAdvertisement } = require('./server/advertisement')
const Options = require('./options') const Options = require('./options')
@ -9,130 +10,76 @@ const debug = globalThis.isElectron ? console.debug : require('debug')('minecraf
class Server extends EventEmitter { class Server extends EventEmitter {
constructor (options) { constructor (options) {
super() super()
this.options = { ...Options.defaultOptions, ...options } this.options = { ...Options.defaultOptions, ...options }
this.validateOptions() this.validateOptions()
this.RakServer = require('./rak')(this.options.raknetBackend).RakServer
this._loadFeatures(this.options.version)
this.serializer = createSerializer(this.options.version) this.serializer = createSerializer(this.options.version)
this.deserializer = createDeserializer(this.options.version) this.deserializer = createDeserializer(this.options.version)
this.advertisement = new ServerAdvertisement(this.options.motd, this.options.port, this.options.version) this.advertisement = new ServerAdvertisement(this.options.motd)
this.advertisement.playersMax = options.maxPlayers ?? 3 this.advertisement.playersMax = options.maxPlayers ?? 3
/** @type {Object<string, Player>} */ /** @type {Object<string, Player>} */
this.clients = {} this.clients = {}
this.clientCount = 0 this.clientCount = 0
this.inLog = (...args) => debug('S ->', ...args)
this.outLog = (...args) => debug('S <-', ...args)
this.conLog = debug this.conLog = debug
this.batchHeader = 0xfe
this.setCompressor(this.options.compressionAlgorithm, this.options.compressionLevel, this.options.compressionThreshold)
}
_loadFeatures (version) {
try {
const mcData = require('minecraft-data')('bedrock_' + version)
this.features = {
compressorInHeader: mcData.supportFeature('compressorInPacketHeader'),
newLoginIdentityFields: mcData.supportFeature('newLoginIdentityFields')
}
} catch (e) {
throw new Error(`Unsupported version: '${version}', no data available`)
}
}
setCompressor (algorithm, level = 1, threshold = 256) {
switch (algorithm) {
case 'none':
this.compressionAlgorithm = 'none'
this.compressionLevel = 0
this.compressionHeader = 255
break
case 'deflate':
this.compressionAlgorithm = 'deflate'
this.compressionLevel = level
this.compressionThreshold = threshold
this.compressionHeader = 0
break
case 'snappy':
this.compressionAlgorithm = 'snappy'
this.compressionLevel = level
this.compressionThreshold = threshold
this.compressionHeader = 1
break
default:
throw new Error(`Unknown compression algorithm: ${algorithm}`)
}
} }
validateOptions () { validateOptions () {
Options.validateOptions(this.options) if (!Options.Versions[this.options.version]) {
} console.warn('Supported versions', Options.Versions)
throw Error(`Unsupported version ${this.options.version}`)
versionLessThan (version) { }
return this.options.protocolVersion < (typeof version === 'string' ? Options.Versions[version] : version) this.options.protocolVersion = Options.Versions[this.options.version]
} if (this.options.protocolVersion < Options.MIN_VERSION) {
throw new Error(`Protocol version < ${Options.MIN_VERSION} : ${this.options.protocolVersion}, too old`)
versionGreaterThan (version) { }
return this.options.protocolVersion > (typeof version === 'string' ? Options.Versions[version] : version)
}
versionGreaterThanOrEqualTo (version) {
return this.options.protocolVersion >= (typeof version === 'string' ? Options.Versions[version] : version)
} }
onOpenConnection = (conn) => { onOpenConnection = (conn) => {
this.conLog('New connection: ', conn?.address) this.conLog('new connection', conn?.address)
const player = new Player(this, conn) const player = new Player(this, conn)
this.clients[conn.address] = player this.clients[conn.address] = player
this.clientCount++ this.clientCount++
this.emit('connect', player) this.emit('connect', player)
} }
onCloseConnection = (conn, reason) => { onCloseConnection = (inetAddr, reason) => {
this.conLog('Connection closed: ', conn.address, reason) this.conLog('close connection', inetAddr?.address, reason)
this.clients[conn.address]?.close() delete this.clients[inetAddr]?.connection // Prevent close loop
delete this.clients[conn.address] this.clients[inetAddr]?.close()
delete this.clients[inetAddr]
this.clientCount-- this.clientCount--
} }
onEncapsulated = (buffer, address) => { onEncapsulated = (buffer, address) => {
// this.inLog('encapsulated', address, buffer)
const client = this.clients[address] const client = this.clients[address]
if (!client) { if (!client) {
// Ignore packets from clients that are not connected. throw new Error(`packet from unknown inet addr: ${address}`)
debug(`Ignoring packet from unknown inet address: ${address}`)
return
} }
client.handle(buffer)
process.nextTick(() => client.handle(buffer))
} }
getAdvertisement () { getAdvertisement () {
if (this.options.advertisementFn) { if (this.options.advertisementFn) {
return this.options.advertisementFn() return this.options.advertisementFn()
} }
this.advertisement.playersOnline = this.clientCount this.advertisement.playersOnline = this.clientCount
return this.advertisement return this.advertisement
} }
async listen () { async listen (host = this.options.host, port = this.options.port) {
const { host, port, maxPlayers } = this.options this.raknet = new RakServer({ host, port }, this)
this.raknet = new this.RakServer({ host, port, maxPlayers }, this)
try { try {
await this.raknet.listen() await this.raknet.listen()
} catch (e) { } catch (e) {
console.warn(`Failed to bind server on [${this.options.host}]/${this.options.port}, is the port free?`) console.warn(`Failed to bind server on [${this.options.host}]/${this.options.port}, is the port free?`)
throw e throw e
} }
this.conLog('Listening on', host, port, this.options.version) this.conLog('Listening on', host, port, this.options.version)
this.raknet.onOpenConnection = this.onOpenConnection this.raknet.onOpenConnection = this.onOpenConnection
this.raknet.onCloseConnection = this.onCloseConnection this.raknet.onCloseConnection = this.onCloseConnection
this.raknet.onEncapsulated = this.onEncapsulated this.raknet.onEncapsulated = this.onEncapsulated
this.raknet.onClose = (reason) => this.close(reason || 'Raknet closed')
this.serverTimer = setInterval(() => { this.serverTimer = setInterval(() => {
this.raknet.updateAdvertisement() this.raknet.updateAdvertisement()
@ -141,7 +88,7 @@ class Server extends EventEmitter {
return { host, port } return { host, port }
} }
async close (disconnectReason = 'Server closed') { async close (disconnectReason) {
for (const caddr in this.clients) { for (const caddr in this.clients) {
const client = this.clients[caddr] const client = this.clients[caddr]
client.disconnect(disconnectReason) client.disconnect(disconnectReason)

View file

@ -3,31 +3,21 @@ const { Versions, CURRENT_VERSION } = require('../options')
class ServerAdvertisement { class ServerAdvertisement {
motd = 'Bedrock Protocol Server' motd = 'Bedrock Protocol Server'
levelName = 'bedrock-protocol' levelName = 'bedrock-protocol'
protocol = Versions[CURRENT_VERSION]
version = CURRENT_VERSION
playersOnline = 0 playersOnline = 0
playersMax = 5 playersMax = 5
gamemode = 'Creative'
serverId = Date.now().toString()
gamemodeId = 1
portV4 = undefined
portV6 = undefined
constructor (obj, port, version = CURRENT_VERSION) { gamemode = 'Creative'
if (obj?.name) obj.motd = obj.name serverId = '0'
this.protocol = Versions[version]
this.version = version constructor (obj, version) {
this.portV4 = port
this.portV6 = port
Object.assign(this, obj) Object.assign(this, obj)
} }
fromString (str) { fromString (str) {
const [header, motd, protocol, version, playersOnline, playersMax, serverId, levelName, gamemode, gamemodeId, portV4, portV6] = str.split(';') const [header, motd, protocol, version, playersOnline, playersMax, serverId, levelName, gamemode] = str.split(';')
Object.assign(this, { header, motd, protocol, version, playersOnline, playersMax, serverId, levelName, gamemode, gamemodeId, portV4, portV6 }) Object.assign(this, { header, motd, protocol, version, playersOnline, playersMax, serverId, levelName, gamemode })
for (const numeric of ['playersOnline', 'playersMax', 'gamemodeId', 'portV4', 'portV6']) {
if (this[numeric] !== undefined) {
this[numeric] = this[numeric] ? parseInt(this[numeric]) : null
}
}
return this return this
} }
@ -41,21 +31,13 @@ class ServerAdvertisement {
this.playersMax, this.playersMax,
this.serverId, this.serverId,
this.levelName, this.levelName,
this.gamemode, this.gamemode
this.gamemodeId,
this.portV4,
this.portV6,
'0'
].join(';') + ';' ].join(';') + ';'
} }
toBuffer (version) { toBuffer (version) {
const str = this.toString(version) const str = this.toString(version)
const length = Buffer.byteLength(str) return Buffer.concat([Buffer.from([0, str.length]), Buffer.from(str)])
const buf = Buffer.alloc(2 + length)
buf.writeUInt16BE(length, 0)
buf.write(str, 2)
return buf
} }
} }

View file

@ -1,16 +1,16 @@
const { ClientStatus, Connection } = require('./connection') const { ClientStatus, Connection } = require('./connection')
const fs = require('fs')
const Options = require('./options') const Options = require('./options')
const { serialize, isDebug } = require('./datatypes/util') const debug = require('debug')('minecraft-protocol')
const { KeyExchange } = require('./handshake/keyExchange') const { KeyExchange } = require('./handshake/keyExchange')
const Login = require('./handshake/login') const Login = require('./handshake/login')
const LoginVerify = require('./handshake/loginVerify') const LoginVerify = require('./handshake/loginVerify')
const debug = require('debug')('minecraft-protocol')
class Player extends Connection { class Player extends Connection {
constructor (server, connection) { constructor (server, connection) {
super() super()
this.server = server this.server = server
this.features = server.features
this.serializer = server.serializer this.serializer = server.serializer
this.deserializer = server.deserializer this.deserializer = server.deserializer
this.connection = connection this.connection = connection
@ -22,79 +22,43 @@ class Player extends Connection {
this.startQueue() this.startQueue()
this.status = ClientStatus.Authenticating this.status = ClientStatus.Authenticating
this.inLog = (...args) => debug('S ->', ...args)
if (isDebug) { this.outLog = (...args) => debug('S <-', ...args)
this.inLog = (...args) => debug('-> S', ...args)
this.outLog = (...args) => debug('<- S', ...args)
}
this.batchHeader = this.server.batchHeader
// Compression is server-wide
this.compressionAlgorithm = this.server.compressionAlgorithm
this.compressionLevel = this.server.compressionLevel
this.compressionThreshold = this.server.compressionThreshold
this.compressionHeader = this.server.compressionHeader
this._sentNetworkSettings = false // 1.19.30+
} }
getUserData () { getUserData () {
return this.userData return this.userData
} }
sendNetworkSettings () {
this.write('network_settings', {
compression_threshold: this.server.compressionThreshold,
compression_algorithm: this.server.compressionAlgorithm,
client_throttle: false,
client_throttle_threshold: 0,
client_throttle_scalar: 0
})
this._sentNetworkSettings = true
this.compressionReady = true
}
handleClientProtocolVersion (clientVersion) {
if (this.server.options.protocolVersion) {
if (this.server.options.protocolVersion < clientVersion) {
this.sendDisconnectStatus('failed_spawn') // client too new
return false
}
} else if (clientVersion < Options.MIN_VERSION) {
this.sendDisconnectStatus('failed_client') // client too old
return false
}
return true
}
onLogin (packet) { onLogin (packet) {
const body = packet.data const body = packet.data
// debug('Login body', body)
this.emit('loggingIn', body) this.emit('loggingIn', body)
const clientVer = body.params.protocol_version const clientVer = body.protocol_version
if (!this.handleClientProtocolVersion(clientVer)) { if (this.server.options.protocolVersion) {
if (this.server.options.protocolVersion < clientVer) {
this.sendDisconnectStatus('failed_spawn')
return
}
} else if (clientVer < Options.MIN_VERSION) {
this.sendDisconnectStatus('failed_client')
return return
} }
// Parse login data // Parse login data
const tokens = body.params.tokens const authChain = JSON.parse(body.params.chain)
const skinChain = body.params.client_data
try { try {
const skinChain = tokens.client var { key, userData, skinData } = this.decodeLoginJWT(authChain.chain, skinChain) // eslint-disable-line
const authChain = JSON.parse(tokens.identity)
let chain
if (authChain.Certificate) { // 1.21.90+
chain = JSON.parse(authChain.Certificate).chain
} else if (authChain.chain) {
chain = authChain.chain
} else {
throw new Error('Invalid login packet: missing chain or Certificate')
}
var { key, userData, skinData } = this.decodeLoginJWT(chain, skinChain) // eslint-disable-line
} catch (e) { } catch (e) {
debug(this.address, e) console.error(e)
console.debug(authChain.chain, skinChain)
this.disconnect('Server authentication error') this.disconnect('Server authentication error')
return return
} }
debug('Verified user pub key', key, userData)
this.emit('server.client_handshake', { key }) // internal so we start encryption this.emit('server.client_handshake', { key }) // internal so we start encryption
@ -103,7 +67,7 @@ class Player extends Connection {
this.profile = { this.profile = {
name: userData.extraData?.displayName, name: userData.extraData?.displayName,
uuid: userData.extraData?.identity, uuid: userData.extraData?.identity,
xuid: userData.extraData?.xuid || userData.extraData?.XUID xuid: userData.extraData?.xuid
} }
this.version = clientVer this.version = clientVer
this.emit('login', { user: userData.extraData }) // emit events for user this.emit('login', { user: userData.extraData }) // emit events for user
@ -126,8 +90,7 @@ class Player extends Connection {
if (this.status === ClientStatus.Disconnected) return if (this.status === ClientStatus.Disconnected) return
this.write('disconnect', { this.write('disconnect', {
hide_disconnect_screen: hide, hide_disconnect_screen: hide,
message: reason, message: reason
filtered_message: ''
}) })
this.server.conLog('Kicked ', this.connection?.address, reason) this.server.conLog('Kicked ', this.connection?.address, reason)
setTimeout(() => this.close('kick'), 100) // Allow time for message to be recieved. setTimeout(() => this.close('kick'), 100) // Allow time for message to be recieved.
@ -136,6 +99,7 @@ class Player extends Connection {
// After sending Server to Client Handshake, this handles the client's // After sending Server to Client Handshake, this handles the client's
// Client to Server handshake response. This indicates successful encryption // Client to Server handshake response. This indicates successful encryption
onHandshake () { onHandshake () {
// this.outLog('Sending login success!', this.status)
// https://wiki.vg/Bedrock_Protocol#Play_Status // https://wiki.vg/Bedrock_Protocol#Play_Status
this.write('play_status', { status: 'login_success' }) this.write('play_status', { status: 'login_success' })
this.status = ClientStatus.Initializing this.status = ClientStatus.Initializing
@ -145,7 +109,7 @@ class Player extends Connection {
close (reason) { close (reason) {
if (this.status !== ClientStatus.Disconnected) { if (this.status !== ClientStatus.Disconnected) {
this.emit('close') // Emit close once this.emit('close') // Emit close once
if (!reason) this.inLog?.('Client closed connection', this.connection?.address) if (!reason) console.trace('Client closed connection', this.connection?.address)
} }
this.q = [] this.q = []
this.q2 = [] this.q2 = []
@ -160,24 +124,14 @@ class Player extends Connection {
var des = this.server.deserializer.parsePacketBuffer(packet) // eslint-disable-line var des = this.server.deserializer.parsePacketBuffer(packet) // eslint-disable-line
} catch (e) { } catch (e) {
this.disconnect('Server error') this.disconnect('Server error')
debug('Dropping packet from', this.connection.address, e) console.warn('Packet parsing failed! Writing dump to ./packetdump.bin')
fs.writeFile('packetdump.bin', packet)
return return
} }
this.inLog?.(des.data.name, serialize(des.data.params))
switch (des.data.name) { switch (des.data.name) {
// This is the first packet on 1.19.30 & above
case 'request_network_settings':
if (this.handleClientProtocolVersion(des.data.params.client_protocol)) {
this.sendNetworkSettings()
this.compressionLevel = this.server.compressionLevel
}
return
// Below 1.19.30, this is the first packet.
case 'login': case 'login':
this.onLogin(des) this.onLogin(des)
if (!this._sentNetworkSettings) this.sendNetworkSettings()
return return
case 'client_to_server_handshake': case 'client_to_server_handshake':
// Emit the 'join' event // Emit the 'join' event
@ -185,19 +139,18 @@ class Player extends Connection {
break break
case 'set_local_player_as_initialized': case 'set_local_player_as_initialized':
this.status = ClientStatus.Initialized this.status = ClientStatus.Initialized
this.inLog?.('Server client spawned') this.inLog('Server client spawned')
// Emit the 'spawn' event // Emit the 'spawn' event
this.emit('spawn') this.emit('spawn')
break break
default: default:
if (this.status === ClientStatus.Disconnected || this.status === ClientStatus.Authenticating) { if (this.status === ClientStatus.Disconnected || this.status === ClientStatus.Authenticating) {
this.inLog?.('ignoring', des.data.name) this.inLog('ignoring', des.data.name)
return return
} }
} }
this.emit(des.data.name, des.data.params) this.emit(des.data.name, des.data.params)
this.emit('packet', des)
} }
} }
module.exports = { Player } module.exports = { Player, ClientStatus }

View file

@ -36,13 +36,12 @@ function createEncryptor (client, iv) {
// The send counter is represented as a little-endian 64-bit long and incremented after each packet. // The send counter is represented as a little-endian 64-bit long and incremented after each packet.
function process (chunk) { function process (chunk) {
const compressed = Zlib.deflateRawSync(chunk, { level: client.compressionLevel }) Zlib.deflateRaw(chunk, { level: 7 }, (err, buffer) => {
const buffer = client.features.compressorInHeader if (err) throw err
? Buffer.concat([Buffer.from([0]), compressed]) const packet = Buffer.concat([buffer, computeCheckSum(buffer, client.sendCounter, client.secretKeyBytes)])
: compressed client.sendCounter++
const packet = Buffer.concat([buffer, computeCheckSum(buffer, client.sendCounter, client.secretKeyBytes)]) client.cipher.write(packet)
client.sendCounter++ })
client.cipher.write(packet)
} }
client.cipher.on('data', client.onEncryptedPacket) client.cipher.on('data', client.onEncryptedPacket)
@ -67,29 +66,14 @@ function createDecryptor (client, iv) {
const computedCheckSum = computeCheckSum(packet, client.receiveCounter, client.secretKeyBytes) const computedCheckSum = computeCheckSum(packet, client.receiveCounter, client.secretKeyBytes)
client.receiveCounter++ client.receiveCounter++
if (!checksum.equals(computedCheckSum)) { if (Buffer.compare(checksum, computedCheckSum) !== 0) {
client.emit('error', Error(`Checksum mismatch ${checksum.toString('hex')} != ${computedCheckSum.toString('hex')}`)) throw Error(`Checksum mismatch ${checksum.toString('hex')} != ${computedCheckSum.toString('hex')}`)
client.disconnect('disconnectionScreen.badPacket')
return
} }
let buffer Zlib.inflateRaw(chunk, { chunkSize: 1024 * 1024 * 2 }, (err, buffer) => {
if (client.features.compressorInHeader) { if (err) throw err
switch (packet[0]) { client.onDecryptedPacket(buffer)
case 0: })
buffer = Zlib.inflateRawSync(packet.slice(1), { chunkSize: 512000 })
break
case 255:
buffer = packet.slice(1)
break
default:
client.emit('error', Error(`Unsupported compressor: ${packet[0]}`))
}
} else {
buffer = Zlib.inflateRawSync(packet, { chunkSize: 512000 })
}
client.onDecryptedPacket(buffer)
} }
client.decipher.on('data', verify) client.decipher.on('data', verify)

View file

@ -3,67 +3,34 @@ const zlib = require('zlib')
// Concatenates packets into one batch packet, and adds length prefixs. // Concatenates packets into one batch packet, and adds length prefixs.
class Framer { class Framer {
constructor (client) { constructor () {
// Encoding // Encoding
this.packets = [] this.packets = []
this.batchHeader = client.batchHeader this.compressionLevel = 7
this.compressor = client.compressionAlgorithm || 'none'
this.compressionLevel = client.compressionLevel
this.compressionThreshold = client.compressionThreshold
this.compressionHeader = client.compressionHeader || 0
this.writeCompressor = client.features.compressorInHeader && client.compressionReady
} }
// No compression in base class static decode (buf, cb) {
compress (buffer) {
switch (this.compressor) {
case 'deflate': return zlib.deflateRawSync(buffer, { level: this.compressionLevel })
case 'snappy': throw Error('Snappy compression not implemented')
case 'none': return buffer
}
}
static decompress (algorithm, buffer) {
switch (algorithm) {
case 0:
case 'deflate':
return zlib.inflateRawSync(buffer, { chunkSize: 512000 })
case 1:
case 'snappy':
throw Error('Snappy compression not implemented')
case 'none':
case 255:
return buffer
default: throw Error('Unknown compression type ' + algorithm)
}
}
static decode (client, buf) {
// Read header // Read header
if (this.batchHeader && buf[0] !== this.batchHeader) throw Error(`bad batch packet header, received: ${buf[0]}, expected: ${this.batchHeader}`) if (buf[0] !== 0xfe) throw Error('bad batch packet header ' + buf[0])
const buffer = buf.slice(1) const buffer = buf.slice(1)
// Decompress
let decompressed // Decode the payload
if (client.features.compressorInHeader && client.compressionReady) { zlib.inflateRaw(buffer, { chunkSize: 1024 * 1024 * 2 }, (err, inflated) => {
decompressed = this.decompress(buffer[0], buffer.slice(1)) if (err) { // Try to decode without compression
} else { Framer.getPackets(buffer)
// On old versions, compressor is session-wide ; failing to decompress return
// a packet will assume it's not compressed
try {
decompressed = this.decompress(client.compressionAlgorithm, buffer)
} catch (e) {
decompressed = buffer
} }
} cb(Framer.getPackets(inflated))
return Framer.getPackets(decompressed) })
} }
encode () { encode (cb) {
const buf = Buffer.concat(this.packets) const buf = Buffer.concat(this.packets)
const shouldCompress = buf.length > this.compressionThreshold zlib.deflateRaw(buf, { level: this.compressionLevel }, (err, def) => {
const header = this.batchHeader ? [this.batchHeader] : [] if (err) throw err
if (this.writeCompressor) header.push(shouldCompress ? this.compressionHeader : 255) const ret = Buffer.concat([Buffer.from([0xfe]), def])
return Buffer.concat([Buffer.from(header), shouldCompress ? this.compress(buf) : buf]) cb(ret)
})
} }
addEncodedPacket (chunk) { addEncodedPacket (chunk) {
@ -108,4 +75,4 @@ class Framer {
} }
} }
module.exports = { Framer } module.exports = Framer

View file

@ -1,16 +1,14 @@
const { ProtoDefCompiler, CompiledProtodef } = require('protodef').Compiler const { ProtoDefCompiler, CompiledProtodef } = require('protodef').Compiler
const { FullPacketParser, Serializer } = require('protodef') const { FullPacketParser, Serializer } = require('protodef')
const { join } = require('path') const { join } = require('path')
const fs = require('fs')
class Parser extends FullPacketParser { class Parser extends FullPacketParser {
dumpFailedBuffer (packet, prefix = '') { parsePacketBuffer (buffer) {
if (packet.length > 1000) { try {
const now = Date.now() return super.parsePacketBuffer(buffer)
fs.writeFileSync(now + '_packetReadError.txt', packet.toString('hex')) } catch (e) {
console.log(prefix, `Deserialization failure for packet 0x${packet.slice(0, 1).toString('hex')}. Packet buffer saved in ./${now}_packetReadError.txt as buffer was too large (${packet.length} bytes).`) console.error('While decoding', buffer.toString('hex'))
} else { throw e
console.log(prefix, 'Read failure for 0x' + packet.slice(0, 1).toString('hex'), packet.slice(0, 1000))
} }
} }
@ -19,24 +17,21 @@ class Parser extends FullPacketParser {
const oldBuffer = deserialized.fullBuffer const oldBuffer = deserialized.fullBuffer
const newBuffer = serializer.createPacketBuffer({ name, params }) const newBuffer = serializer.createPacketBuffer({ name, params })
if (!newBuffer.equals(oldBuffer)) { if (!newBuffer.equals(oldBuffer)) {
const fs = require('fs') console.warn('New', newBuffer.toString('hex'))
fs.writeFileSync('new.bin', newBuffer) console.warn('Old', oldBuffer.toString('hex'))
fs.writeFileSync('old.bin', oldBuffer) console.log('Failed to re-encode', name, params)
fs.writeFileSync('failed.json', JSON.stringify(params, (k, v) => typeof v === 'bigint' ? v.toString() : v, 2)) process.exit(1)
console.warn('Failed to re-encode', name)
} }
} }
} }
// Compiles the ProtoDef schema at runtime // Compiles the ProtoDef schema at runtime
function createProtocol (version) { function createProtocol (version) {
// Try and load from .js if available const protocol = require(join(__dirname, `../../data/${version}/protocol.json`)).types
try { require(`../../data/${version}/size.js`); return getProtocol(version) } catch {}
const protocol = require('minecraft-data')('bedrock_' + version).protocol
const compiler = new ProtoDefCompiler() const compiler = new ProtoDefCompiler()
compiler.addTypesToCompile(protocol.types) compiler.addTypesToCompile(protocol)
compiler.addTypes(require('../datatypes/compiler-minecraft')) compiler.addTypes(require(join(__dirname, '../datatypes/compiler-minecraft')))
compiler.addTypes(require('prismarine-nbt/compiler-zigzag'))
const compiledProto = compiler.compileProtoDefSync() const compiledProto = compiler.compileProtoDefSync()
return compiledProto return compiledProto
@ -46,6 +41,7 @@ function createProtocol (version) {
function getProtocol (version) { function getProtocol (version) {
const compiler = new ProtoDefCompiler() const compiler = new ProtoDefCompiler()
compiler.addTypes(require(join(__dirname, '../datatypes/compiler-minecraft'))) compiler.addTypes(require(join(__dirname, '../datatypes/compiler-minecraft')))
compiler.addTypes(require('prismarine-nbt/compiler-zigzag'))
global.PartialReadError = require('protodef/src/utils').PartialReadError global.PartialReadError = require('protodef/src/utils').PartialReadError
const compile = (compiler, file) => require(file)(compiler.native) const compile = (compiler, file) => require(file)(compiler.native)
@ -58,18 +54,17 @@ function getProtocol (version) {
} }
function createSerializer (version) { function createSerializer (version) {
const proto = createProtocol(version) const proto = getProtocol(version)
return new Serializer(proto, 'mcpe_packet') return new Serializer(proto, 'mcpe_packet')
} }
function createDeserializer (version) { function createDeserializer (version) {
const proto = createProtocol(version) const proto = getProtocol(version)
return new Parser(proto, 'mcpe_packet') return new Parser(proto, 'mcpe_packet')
} }
module.exports = { module.exports = {
createDeserializer, createDeserializer: createDeserializer,
createSerializer, createSerializer: createSerializer,
createProtocol, createProtocol: createProtocol
getProtocol
} }

View file

@ -1,10 +1,7 @@
const { Server, Client } = require('../') const { Server, Client } = require('../')
const { dumpPackets } = require('../tools/genPacketDumps') const { dumpPackets } = require('../tools/genPacketDumps')
const DataProvider = require('../data/provider')
const { ping } = require('../src/createClient') const { ping } = require('../src/createClient')
const { CURRENT_VERSION } = require('../src/options')
const { join } = require('path')
const { waitFor } = require('../src/datatypes/util')
const { getPort } = require('./util')
// First we need to dump some packets that a vanilla server would send a vanilla // 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. // client. Then we can replay those back in our custom server.
@ -12,21 +9,20 @@ function prepare (version) {
return dumpPackets(version) return dumpPackets(version)
} }
async function startTest (version = CURRENT_VERSION, ok) { async function startTest (version = '1.16.220', ok) {
await prepare(version) await prepare(version)
// const Item = require('../types/Item')(version) const Item = require('../types/Item')(version)
const port = await getPort() const port = 19130
const server = new Server({ host: '0.0.0.0', port, version, offline: true }) const server = new Server({ host: '0.0.0.0', port, version, offline: true })
function getPath (packetPath) { function getPath (packetPath) {
return join(__dirname, `../data/${server.options.version}/${packetPath}`) return DataProvider(server.options.protocolVersion).getPath(packetPath)
} }
function get (packetPath) { function get (packetPath) {
return require(getPath('sample/' + packetPath)) return require(getPath('sample/' + packetPath))
} }
console.log('Starting internal server')
server.listen() server.listen()
console.log('Started server') console.log('Started server')
@ -34,7 +30,7 @@ async function startTest (version = CURRENT_VERSION, ok) {
console.assert(pongData, 'did not get valid pong data from server') console.assert(pongData, 'did not get valid pong data from server')
const respawnPacket = get('packets/respawn.json') const respawnPacket = get('packets/respawn.json')
const chunks = await requestChunks(version, respawnPacket.x, respawnPacket.z, 1) const chunks = await requestChunks(respawnPacket.x, respawnPacket.z, 1)
let loop let loop
@ -47,9 +43,7 @@ async function startTest (version = CURRENT_VERSION, ok) {
must_accept: false, must_accept: false,
has_scripts: false, has_scripts: false,
behaviour_packs: [], behaviour_packs: [],
world_template: { uuid: '550e8400-e29b-41d4-a716-446655440000', version: '' }, // 1.21.50 texture_packs: []
texture_packs: [],
resource_pack_links: []
}) })
client.once('resource_pack_client_response', async rp => { client.once('resource_pack_client_response', async rp => {
@ -57,25 +51,18 @@ async function startTest (version = CURRENT_VERSION, ok) {
client.write('network_settings', { compression_threshold: 1 }) client.write('network_settings', { compression_threshold: 1 })
// Send some inventory slots // Send some inventory slots
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
// client.queue('inventory_slot', { window_id: 'armor', slot: 0, item: new Item().toBedrock() }) client.queue('inventory_slot', { window_id: 'armor', slot: 0, item: new Item().toBedrock() })
} }
// client.queue('inventory_transaction', get('packets/inventory_transaction.json')) // client.queue('inventory_transaction', get('packets/inventory_transaction.json'))
client.queue('player_list', get('packets/player_list.json')) client.queue('player_list', get('packets/player_list.json'))
client.queue('start_game', get('packets/start_game.json')) client.queue('start_game', get('packets/start_game.json'))
if (client.versionLessThan('1.21.60')) { client.queue('item_component', { entries: [] })
client.queue('item_component', { entries: [] })
} else {
client.queue('item_registry', get('packets/item_registry.json'))
}
client.queue('set_spawn_position', get('packets/set_spawn_position.json')) client.queue('set_spawn_position', get('packets/set_spawn_position.json'))
client.queue('set_time', { time: 5433771 }) client.queue('set_time', { time: 5433771 })
client.queue('set_difficulty', { difficulty: 1 }) client.queue('set_difficulty', { difficulty: 1 })
client.queue('set_commands_enabled', { enabled: true }) client.queue('set_commands_enabled', { enabled: true })
client.queue('adventure_settings', get('packets/adventure_settings.json'))
if (client.versionLessThan('1.19.10')) {
client.queue('adventure_settings', get('packets/adventure_settings.json'))
}
client.queue('biome_definition_list', get('packets/biome_definition_list.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('available_entity_identifiers', get('packets/available_entity_identifiers.json'))
@ -101,11 +88,11 @@ async function startTest (version = CURRENT_VERSION, ok) {
loop = setInterval(() => { loop = setInterval(() => {
client.write('network_chunk_publisher_update', { coordinates: { x: 646, y: 130, z: 77 }, radius: 64 }) client.write('network_chunk_publisher_update', { coordinates: { x: 646, y: 130, z: 77 }, radius: 64 })
}, 6500) }, 9500)
setTimeout(() => { setTimeout(() => {
client.write('play_status', { status: 'player_spawn' }) client.write('play_status', { status: 'player_spawn' })
}, 3000) }, 6000)
// Respond to tick synchronization packets // Respond to tick synchronization packets
client.on('tick_sync', (packet) => { client.on('tick_sync', (packet) => {
@ -162,31 +149,31 @@ async function startTest (version = CURRENT_VERSION, ok) {
client.connect() client.connect()
} }
async function requestChunks (version, x, z, radius) { const { ChunkColumn, Version } = require('bedrock-provider')
const ChunkColumn = require('bedrock-provider').chunk('bedrock_1.17.10') const { waitFor } = require('../src/datatypes/util')
// const mcData = require('minecraft-data')('1.16') const mcData = require('minecraft-data')('1.16')
async function requestChunks (x, z, radius) {
const cxStart = (x >> 4) - radius const cxStart = (x >> 4) - radius
const cxEnd = (x >> 4) + radius const cxEnd = (x >> 4) + radius
const czStart = (z >> 4) - radius const czStart = (z >> 4) - radius
const czEnd = (z >> 4) + radius const czEnd = (z >> 4) + radius
// const stone = mcData.blocksByName.stone const stone = mcData.blocksByName.stone
const chunks = [] const chunks = []
for (let cx = cxStart; cx < cxEnd; cx++) { for (let cx = cxStart; cx < cxEnd; cx++) {
for (let cz = czStart; cz < czEnd; cz++) { for (let cz = czStart; cz < czEnd; cz++) {
console.log('reading chunk at ', cx, cz) console.log('reading chunk at ', cx, cz)
const cc = new ChunkColumn(x, z) const cc = new ChunkColumn(Version.v1_2_0_bis, x, z)
// Temporarily disable until 1.18 PR in bedrock-provider goes through for (let x = 0; x < 16; x++) {
// for (let x = 0; x < 16; x++) { for (let y = 0; y < 60; y++) {
// for (let y = 0; y < 60; y++) { for (let z = 0; z < 16; z++) {
// for (let z = 0; z < 16; z++) { cc.setBlock(x, y, z, stone)
// cc.setBlock({ x, y, z }, stone) }
// } }
// } }
// }
if (!cc) { if (!cc) {
console.log('no chunk') console.log('no chunk')
@ -201,21 +188,21 @@ async function requestChunks (version, x, z, radius) {
blobs: [], blobs: [],
payload: cbuf payload: cbuf
}) })
// console.log('Ht',cc.sectionsLen,cc.sections)
} }
} }
return chunks return chunks
} }
async function timedTest (version, timeout = 1000 * 220) { async function timedTest (version, timeout = 1000 * 120) {
await waitFor((resolve, reject) => { await waitFor((res) => {
// mocha eats up stack traces... startTest(version, res)
startTest(version, resolve).catch(reject)
}, timeout, () => { }, timeout, () => {
throw Error('timed out') throw Error('timed out')
}) })
console.info('✔ ok') console.info('✔ ok')
} }
// if (!module.parent) timedTest('1.19.10') if (!module.parent) timedTest()
module.exports = { startTest, timedTest, requestChunks } module.exports = { startTest, timedTest, requestChunks }

View file

@ -1,19 +1,23 @@
/* eslint-env jest */ /* eslint-env jest */
const { timedTest } = require('./internal') const { timedTest } = require('./internal')
const { testedVersions } = require('../src/options') const { proxyTest } = require('./proxy')
const { sleep } = require('../src/datatypes/util') const { Versions } = require('../src/options')
require('events').captureRejections = true
describe('internal client/server test', function () { describe('internal client/server test', function () {
const vcount = testedVersions.length this.timeout(240 * 1000)
this.timeout(vcount * 80 * 1000)
for (const version of testedVersions) { for (const version in Versions) {
it('connects ' + version, async () => { it('connects ' + version, async () => {
console.debug(version) console.debug(version)
await timedTest(version) await timedTest(version)
await sleep(100) })
}
for (const version in Versions) {
it('proxies ' + version, async () => {
console.debug(version)
await proxyTest(version)
}) })
} }
}) })

View file

@ -1,68 +1,59 @@
const { createClient, Server, Relay } = require('bedrock-protocol') const { createClient, createServer, Relay } = require('bedrock-protocol')
const { sleep, waitFor } = require('../src/datatypes/util') const { sleep, waitFor } = require('../src/datatypes/util')
const { getPort } = require('./util')
function proxyTest (version, raknetBackend = 'raknet-native', timeout = 1000 * 40) { function proxyTest (version, timeout = 1000 * 40) {
console.log('with raknet backend', raknetBackend) return waitFor(res => {
return waitFor(async res => { const server = createServer({
const SERVER_PORT = await getPort()
const CLIENT_PORT = await getPort()
const server = new Server({
host: '0.0.0.0', // optional host: '0.0.0.0', // optional
port: SERVER_PORT, // optional port: 19131, // optional
offline: true, offline: true,
raknetBackend,
version // The server version version // The server version
}) })
await server.listen()
server.on('connect', client => { server.on('connect', client => {
console.debug('Client has connected')
client.on('join', () => { // The client has joined the server. client.on('join', () => { // The client has joined the server.
console.debug('Client has authenticated')
setTimeout(() => { setTimeout(() => {
client.disconnect('Hello world !') client.disconnect('Hello world !')
}, 500) // allow some time for client to connect }, 1000) // allow some time for client to connect
}) })
}) })
console.debug('Server started', server.options.version) console.debug('Server started', server.options.version)
await new Promise(resolve => setTimeout(resolve, 500))
const relay = new Relay({ const relay = new Relay({
version, version,
offline: true, offline: true,
/* host and port for clients to listen to */ /* host and port for clients to listen to */
host: '0.0.0.0', host: '0.0.0.0',
port: CLIENT_PORT, port: 19132,
/* Where to send upstream packets to */ /* Where to send upstream packets to */
destination: { destination: {
host: '127.0.0.1', host: '127.0.0.1',
port: SERVER_PORT port: 19131
}, }
raknetBackend
}) })
relay.conLog = console.debug relay.conLog = console.debug
await relay.listen() relay.listen()
console.debug('Proxy started', server.options.version) console.debug('Proxy started', server.options.version)
await new Promise(resolve => setTimeout(resolve, 500))
const client = createClient({ host: '127.0.0.1', port: CLIENT_PORT, version, username: 'Boat', offline: true, raknetBackend, skipPing: true }) const client = createClient({ host: '127.0.0.1', version, username: 'Boat', offline: true })
console.debug('Client started') console.debug('Client started')
client.on('error', console.log)
client.on('packet', console.log)
client.on('disconnect', packet => { client.on('disconnect', packet => {
console.assert(packet.message === 'Hello world !') console.assert(packet.message === 'Hello world !')
server.close() server.close()
relay.close() relay.close()
console.log('✔ OK') console.log('✔ OK')
sleep(200).then(res) sleep(500).then(res)
}) })
}, timeout, () => { throw Error('timed out') }) }, timeout, () => { throw Error('timed out') })
} }
// if (!module.parent) { proxyTest('1.16.220', 'raknet-native') } if (!module.parent) {
proxyTest('1.16.220')
}
module.exports = { proxyTest } module.exports = { proxyTest }

View file

@ -1,18 +0,0 @@
/* eslint-env jest */
const { proxyTest } = require('./proxy')
const { testedVersions } = require('../src/options')
const { sleep } = require('../src/datatypes/util')
describe('proxies client/server', function () {
const vcount = testedVersions.length
this.timeout(vcount * 30 * 1000)
for (const version of testedVersions) {
it('proxies ' + version, async () => {
console.debug(version)
await proxyTest(version)
await sleep(100)
console.debug('Done', version)
})
}
})

View file

@ -1,17 +0,0 @@
const net = require('net')
const getPort = () => new Promise(resolve => {
const server = net.createServer()
server.listen(0, '127.0.0.1')
server.on('listening', () => {
const { port } = server.address()
server.close(() => {
// Wait a bit for port to free as we try to bind right after freeing it
setTimeout(() => {
resolve(port)
}, 200)
})
})
})
module.exports = { getPort }

View file

@ -2,23 +2,19 @@
const vanillaServer = require('../tools/startVanillaServer') const vanillaServer = require('../tools/startVanillaServer')
const { Client } = require('../src/client') const { Client } = require('../src/client')
const { waitFor } = require('../src/datatypes/util') const { waitFor } = require('../src/datatypes/util')
const { getPort } = require('./util') const { ChunkColumn, Version } = require('bedrock-provider')
const { CURRENT_VERSION } = require('../src/options')
async function test (version) { async function test (version) {
// const ChunkColumn = require('bedrock-provider').chunk('bedrock_' + (version.includes('1.19') ? '1.18.30' : version)) // TODO: Fix prismarine-chunk
// Start the server, wait for it to accept clients, throws on timeout // Start the server, wait for it to accept clients, throws on timeout
const [port, v6] = [await getPort(), await getPort()] const handle = await vanillaServer.startServerAndWait(version, 1000 * 220)
console.log('Starting vanilla server', version, 'on port', port, v6)
const handle = await vanillaServer.startServerAndWait2(version, 1000 * 220, { 'server-port': port, 'server-portv6': v6 })
console.log('Started server') console.log('Started server')
const client = new Client({ const client = new Client({
host: '127.0.0.1', host: '127.0.0.1',
port, port: 19130,
username: 'Notch', username: 'Notch',
version, version,
raknetBackend: 'raknet-native',
offline: true offline: true
}) })
@ -49,10 +45,10 @@ async function test (version) {
client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) }) client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) })
}, 200) }, 200)
// client.on('level_chunk', async packet => { // Chunk read test client.on('level_chunk', async packet => { // Chunk read test
// const cc = new ChunkColumn(packet.x, packet.z) const cc = new ChunkColumn(Version.v1_4_0, packet.x, packet.z)
// await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count) await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count)
// }) })
console.log('Awaiting join') console.log('Awaiting join')
@ -71,4 +67,5 @@ async function test (version) {
clearInterval(loop) clearInterval(loop)
} }
if (!module.parent) test(CURRENT_VERSION)
module.exports = { clientTest: test } module.exports = { clientTest: test }

View file

@ -1,17 +1,14 @@
/* eslint-env jest */ /* eslint-env jest */
const { clientTest } = require('./vanilla') const { clientTest } = require('./vanilla')
const { testedVersions } = require('../src/options') const { Versions } = require('../src/options')
const { sleep } = require('../src/datatypes/util')
describe('vanilla server test', function () { describe('vanilla server test', function () {
const vcount = testedVersions.length this.timeout(220 * 1000)
this.timeout(vcount * 80 * 1000)
for (const version of testedVersions) { for (const version in Versions) {
it('client spawns ' + version, async () => { it('client spawns ' + version, async () => {
await clientTest(version) await clientTest(version)
await sleep(100)
}) })
} }
}) })

View file

@ -1,20 +1,58 @@
/** /**
* Pre-compiles JS code from the schema for easier development. * This is a utility script that converts the YAML here into ProtoDef schema code and (soon) docs/typescript definitions.
* It also pre-compiles JS code from the schema for easier development.
*
* You can run this with `npm run build` * You can run this with `npm run build`
*
*/ */
const fs = require('fs') const fs = require('fs')
const { ProtoDefCompiler } = require('protodef').Compiler const { ProtoDefCompiler } = require('protodef').Compiler
const { convert } = require('minecraft-data/minecraft-data/tools/js/compileProtocol') const { Versions } = require('../src/options')
const mcData = require('minecraft-data')
const { join } = require('path') const { join } = require('path')
// Filter versions we support
const versions = mcData.versions.bedrock.filter(e => e.releaseType === 'release').map(e => e.minecraftVersion) function getJSON (path) {
return JSON.parse(fs.readFileSync(path, 'utf-8'))
}
// Parse the YML files and turn to JSON
function genProtoSchema () {
const { parse, compile } = require('protodef-yaml/compiler')
// Create the packet_map.yml from proto.yml
const parsed = parse('./proto.yml')
const version = parsed['!version']
const packets = []
for (const key in parsed) {
if (key.startsWith('%container')) {
const [, name] = key.split(',')
if (name.startsWith('packet_')) {
const children = parsed[key]
const packetName = name.replace('packet_', '')
const packetID = children['!id']
packets.push([packetID, packetName, name])
}
}
}
let l1 = ''
let l2 = ''
for (const [id, name, fname] of packets) {
l1 += ` 0x${id.toString(16).padStart(2, '0')}: ${name}\n`
l2 += ` if ${name}: ${fname}\n`
}
// TODO: skip creating packet_map.yml and just generate the ProtoDef map JSON directly
const t = `#Auto-generated from proto.yml, do not modify\n!import: types.yaml\nmcpe_packet:\n name: varint =>\n${l1}\n params: name ?\n${l2}`
fs.writeFileSync('./packet_map.yml', t)
compile('./proto.yml', 'proto.json')
return version
}
// Compile the ProtoDef JSON into JS // Compile the ProtoDef JSON into JS
function createProtocol (version) { function createProtocol () {
const compiler = new ProtoDefCompiler() const compiler = new ProtoDefCompiler()
const protocol = mcData('bedrock_' + version).protocol.types const protocol = getJSON('./protocol.json').types
compiler.addTypes(require('../src/datatypes/compiler-minecraft')) compiler.addTypes(require('../src/datatypes/compiler-minecraft'))
compiler.addTypes(require('prismarine-nbt/compiler-zigzag'))
compiler.addTypesToCompile(protocol) compiler.addTypesToCompile(protocol)
fs.writeFileSync('./read.js', 'module.exports = ' + compiler.readCompiler.generate().replace('() =>', 'native =>')) fs.writeFileSync('./read.js', 'module.exports = ' + compiler.readCompiler.generate().replace('() =>', 'native =>'))
@ -25,21 +63,27 @@ function createProtocol (version) {
return compiledProto return compiledProto
} }
function copyLatest () {
process.chdir(join(__dirname, '/../data/latest'))
const version = genProtoSchema()
try { fs.mkdirSync(`../${version}`) } catch {}
fs.writeFileSync(`../${version}/protocol.json`, JSON.stringify({ types: getJSON('./proto.json') }, null, 2))
fs.unlinkSync('./proto.json') // remove temp file
fs.unlinkSync('./packet_map.yml') // remove temp file
return version
}
function main (ver = 'latest') { function main (ver = 'latest') {
// Put the .js files into the data/ dir, we also use the data dir when dumping packets for tests if (ver === 'latest') ver = copyLatest()
const dir = join(__dirname, '/../data/', ver) process.chdir(join(__dirname, '/../data/', ver))
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
process.chdir(dir)
console.log('Generating JS...', ver) console.log('Generating JS...', ver)
createProtocol(ver) createProtocol(ver)
} }
require('minecraft-data/bin/generate_data')
// If no argument, build everything // If no argument, build everything
if (!process.argv[2]) { if (!process.argv[2]) {
convert('bedrock', 'latest') copyLatest()
for (const version of versions) { for (const version in Versions) {
main(version) main(version)
} }
} else { // build the specified version } else { // build the specified version

View file

@ -6,41 +6,38 @@ const { Client } = require('../src/client')
const { serialize, waitFor, getFiles } = require('../src/datatypes/util') const { serialize, waitFor, getFiles } = require('../src/datatypes/util')
const { CURRENT_VERSION } = require('../src/options') const { CURRENT_VERSION } = require('../src/options')
const { join } = require('path') const { join } = require('path')
const { getPort } = require('../test/util')
function hasDumps (version) { function hasDumps (version) {
const root = join(__dirname, `../data/${version}/sample/packets/`) const root = join(__dirname, `../data/${version}/sample/packets/`)
if (!fs.existsSync(root) || getFiles(root).length < 10) { if (!fs.existsSync(root) || getFiles(root).length < 10) {
return false return false
} }
return true return true
} }
let loop let loop
async function dump (version, force = true) { async function dump (version, force = true) {
const random = (Math.random() * 1000) | 0 const random = ((Math.random() * 100) | 0)
const [port, v6] = [await getPort(), await getPort()] const port = 19130 + random
console.log('Starting dump server', version, 'on port', port, v6) const handle = await vanillaServer.startServerAndWait(version || CURRENT_VERSION, 1000 * 120, { 'server-port': port })
const handle = await vanillaServer.startServerAndWait2(version || CURRENT_VERSION, 1000 * 120, { 'server-port': port, 'server-portv6': v6 })
console.log('Started dump server', version) console.log('Started dump server')
const client = new Client({ const client = new Client({
host: '127.0.0.1', host: '127.0.0.1',
port, port,
version, version,
username: 'Packet' + random, username: 'Boat' + random,
offline: true offline: true
}) })
client.connect() client.connect()
return waitFor(async res => { return waitFor(async res => {
const root = join(__dirname, `../data/${client.options.version}/sample/`) 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 + 'packets', { recursive: true })
fs.mkdirSync(root + 'chunks', { recursive: true }) fs.mkdirSync(root + 'chunks', { recursive: true })
}
client.once('resource_packs_info', (packet) => { client.once('resource_packs_info', (packet) => {
client.write('resource_pack_client_response', { client.write('resource_pack_client_response', {
@ -57,9 +54,9 @@ async function dump (version, force = true) {
client.queue('client_cache_status', { enabled: false }) client.queue('client_cache_status', { enabled: false })
client.queue('request_chunk_radius', { chunk_radius: 1 }) client.queue('request_chunk_radius', { chunk_radius: 1 })
// client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n })
clearInterval(loop) clearInterval(loop)
loop = setInterval(() => { loop = setInterval(() => {
client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) }) client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: BigInt(Date.now()) })
}, 200) }, 200)
@ -69,12 +66,10 @@ async function dump (version, force = true) {
client.on('packet', async packet => { // Packet dumping client.on('packet', async packet => { // Packet dumping
const { name, params } = packet.data const { name, params } = packet.data
if (name === 'level_chunk') { if (name === 'level_chunk') {
fs.writeFileSync(root + `chunks/${name}-${i++}.bin`, packet.buffer) fs.writeFileSync(root + `chunks/${name}-${i++}.bin`, packet.buffer)
return return
} }
try { try {
if (!fs.existsSync(root + `packets/${name}.json`) || force) { if (!fs.existsSync(root + `packets/${name}.json`) || force) {
fs.writeFileSync(root + `packets/${name}.json`, serialize(params, 2)) fs.writeFileSync(root + `packets/${name}.json`, serialize(params, 2))
@ -86,7 +81,6 @@ async function dump (version, force = true) {
client.on('spawn', () => { client.on('spawn', () => {
console.log('Spawned!') console.log('Spawned!')
clearInterval(loop) clearInterval(loop)
client.close() client.close()
handle.kill() handle.kill()
@ -95,7 +89,7 @@ async function dump (version, force = true) {
}, 1000 * 60, () => { }, 1000 * 60, () => {
clearInterval(loop) clearInterval(loop)
handle.kill() handle.kill()
throw Error('Timed out') throw Error('timed out')
}) })
} }

View file

@ -1,8 +0,0 @@
rm -fr pmmp
mkdir pmmp && cd pmmp
wget https://github.com/pmmp/PHP-Binaries/releases/download/php-8.1-latest/PHP-Linux-x86_64-PM5.tar.gz
tar -xvf PHP-Linux-x86_64-PM5.tar.gz bin/
git clone https://github.com/pmmp/PocketMine-MP.git
cd PocketMine-MP
../bin/php7/bin/php /usr/bin/composer install
../bin/php7/bin/php src/PocketMine.php --no-wizard --xbox-auth=0 --settings.enable-dev-builds=1 --anonymous-statistics.enabled=0 --disable-readline --debug.level=2

View file

@ -1,11 +1,114 @@
const bedrockServer = require('minecraft-bedrock-server') const http = require('https')
const fs = require('fs')
const cp = require('child_process')
const debug = require('debug')('minecraft-protocol')
const { getFiles, waitFor } = require('../src/datatypes/util')
module.exports = { const head = (url) => new Promise((resolve, reject) => http.request(url, { method: 'HEAD' }, resolve).on('error', reject).end())
...bedrockServer, const get = (url, out) => cp.execSync(`curl -o ${out} ${url}`)
startServerAndWait (version, withTimeout, options) {
return bedrockServer.startServerAndWait(version, withTimeout, { ...options, root: __dirname }) // Get the latest versions
}, // TODO: once we support multi-versions
startServerAndWait2 (version, withTimeout, options) { function fetchLatestStable () {
return bedrockServer.startServerAndWait2(version, withTimeout, { ...options, root: __dirname }) get('https://raw.githubusercontent.com/minecraft-linux/mcpelauncher-versiondb/master/versions.json', 'versions.json')
} const versions = JSON.parse(fs.readFileSync('./versions.json'))
const latest = versions[versions.length - 1]
return latest.version_name
} }
// Download + extract vanilla server and enter the directory
async function download (os, version, path = 'bds-') {
process.chdir(__dirname)
const verStr = version.split('.').slice(0, 3).join('.')
const dir = path + version
if (fs.existsSync(dir) && getFiles(dir).length) {
process.chdir(path + version) // Enter server folder
return verStr
}
try { fs.mkdirSync(dir) } catch { }
process.chdir(path + version) // Enter server folder
const url = (os, version) => `https://minecraft.azureedge.net/bin-${os}/bedrock-server-${version}.zip`
let found = false
for (let i = 0; i < 8; i++) { // Check for the latest server build for version (major.minor.patch.BUILD)
const u = url(os, `${verStr}.${String(i).padStart(2, '0')}`)
debug('Opening', u)
const ret = await head(u)
if (ret.statusCode === 200) {
found = u
debug('Found server', ret.statusCode)
break
}
}
if (!found) throw Error('did not find server bin for ' + os + ' ' + version)
console.info('🔻 Downloading', found)
get(found, 'bds.zip')
console.info('⚡ Unzipping')
// Unzip server
if (process.platform === 'linux') cp.execSync('unzip bds.zip && chmod +777 ./bedrock_server')
else cp.execSync('tar -xf bds.zip')
return verStr
}
const defaultOptions = {
'level-generator': '2',
'server-port': '19130',
'online-mode': 'false'
}
// Setup the server
function configure (options = {}) {
const opts = { ...defaultOptions, ...options }
let config = fs.readFileSync('./server.properties', 'utf-8')
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)
}
function run (inheritStdout = true) {
const exe = process.platform === 'win32' ? 'bedrock_server.exe' : './bedrock_server'
return cp.spawn(exe, inheritStdout ? { stdio: 'inherit' } : {})
}
// Run the server
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, options.path)
configure(options)
const handle = run(!onStart)
if (onStart) {
let stdout = ''
handle.stdout.on('data', data => {
stdout += data
if (stdout.includes('Server started')) onStart()
})
handle.stdout.pipe(process.stdout)
handle.stderr.pipe(process.stdout)
}
return handle
}
// Start the server and wait for it to be ready, with a timeout
async function startServerAndWait (version, withTimeout, options) {
let handle
await waitFor(async res => {
handle = await startServer(version, res, options)
}, withTimeout, () => {
handle?.kill()
throw new Error('Server did not start on time ' + withTimeout)
})
return handle
}
if (!module.parent) {
// if (process.argv.length < 3) throw Error('Missing version argument')
startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3], 'online-mode': !!process.argv[4] } : undefined)
}
module.exports = { fetchLatestStable, startServer, startServerAndWait }

View file

@ -1,5 +1,3 @@
const { Versions } = require('../src/options')
module.exports = (version) => module.exports = (version) =>
class Item { class Item {
nbt nbt
@ -13,7 +11,7 @@ module.exports = (version) =>
} }
static fromBedrock (obj) { static fromBedrock (obj) {
if (Versions[version] >= Versions['1.16.220']) { if (version === '1.16.220') {
return new Item({ return new Item({
networkId: obj.network_id, networkId: obj.network_id,
stackId: obj.stack_id, stackId: obj.stack_id,
@ -34,7 +32,7 @@ module.exports = (version) =>
} }
toBedrock () { toBedrock () {
if (Versions[version] >= Versions['1.16.220']) { if (version === '1.16.220') {
return { return {
network_id: this.networkId, network_id: this.networkId,
count: this.count, count: this.count,