Minecraftサーバー

0から作るMinecraftサーバー #1TCPサーバーとVarInt

Minecraftは、長い間人気を誇っているサンドボックスゲームです。

Minecraftには、複数人でとマルチプレイで遊べるサーバーが数多く存在します。

例えば、公式サーバー、Forge、Spigotなど、、、

今回は、それらのツールを用いずに、ゼロからMinecraftのサーバーを作っていきたいと思います。

私はSwiftのプロフェッショナルではないため、合理的な実装ではない可能性があります。コメントにて間違っているところやより良い実装について指摘していただけると泣いて喜びます。

使用する言語の選択

使用する言語でTCP通信を行うことができることを確認してください。TCP通信を行うことができれば、使用する言語に制限はありません。今回は、Swiftを用いてMacで開発を行います。

Minecraftの通信規格はサーバーの使用する言語に依らないため、TCP通信が行えれば、サーバーを実装することができます、この記事では、Swiftを用いて実装するため、Swiftで説明しますが、他言語でも同じような処理を行えるはずです。

参考文献

https://wiki.vg/Protocol

Minecraftとサーバーの通信規格について詳しく述べられています。

これに基づいて実装を行なっていきます。

この記事では、Minecraftのバージョンを1.20.4として説明します。バージョンによって、プロトコルは変更される可能性があります。https://wiki.vg/ではかなり頻繁に内容が更新されているため、一読することをお勧めします。

TCP Listenerの作成

Minecraftとサーバーの通信はTCPで行われます。まず、Minecraftとの通信を見てみるために、TCP Listenerを作ってみましょう。

開発はXcodeで、MacOSのcommand line toolをテンプレートとして作成しました。

TCP Listenerは、指定したポートに送信されたTCPパケットをそのまま表示するような実装にします。TCPServerというクラスを作成し、その中でTCPListnerの処理を書くことにします。

import Foundation
import Network
let myQueue = DispatchQueue(label: "TCPListener")
class TCPServer {
    private var listener: NWListener?
    
    func startServer() {
        do {
            listener = try NWListener(using: .tcp, on: NWEndpoint.Port(25565)
            listener?.newConnectionHandler = { newConnection in
                self.connectionStart(newConnection)
            }
            listener?.start(queue: myQueue)
            print(listener?.state ?? "Listener is not ready")
            print("Server Started on port \(String(describing: listener?.port))")
        } catch {
             print("listener error: \(error)")
        }
    }

    func connectionStart(_ connection: NWConnection) {
        print("New client connection from \(connection.endpoint)")
        connection.start(queue: myQueue)
        handleConnection(connection)
    }

    func handleConnection(_ connection: NWConnection) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { (data, context, isComplete, error) in
            if let data = data, !data.isEmpty {
                let recieveString = String(decoding: data, as UTF8.self)
                print(recieveString)
                print(data.map { String(format: "%02x", $0)}.joined(separator: " ")}
            }
            if isComplete || error != nil {
                connection.cancel()
                print("Connection ended from \(connection.endpoint)")
            } else {
                self.handleConnection(connection)
                print("Continue to receive from \(connection.endpoint)")
            }
        }
    }
}
import Foundation

let server = TCPServer()
//サーバーの起動
server.startServer()
//サーバーの起動を維持
RunLoop.main.run()

TCPServerクラスでは、クライアントとの通信を管理し、mainではこのクラスをインスタンス化して使用します。TCPServerクラスについて詳しく説明します。

startServer関数では、NetworkフレームワークのNWListenerを使用して、指定したポート(今回では25565)でListenを行います。新しいconnectionが追加されると、connectionStartを呼び出します。また、ネットワーク通信は非同期処理となるので、DispatchQueueを使い、イベントがキューに登録されるようにします。

connectionStart関数では、新しいクライアントとの接続を確立し、DispatchQueueで、イベントがキューに登録されるようにします。

handleConnection関数では、クライアントからデータが送信されるのを待ち、データを受信すると、UTF8でエンコードしたデータと、生のデータ(16進数で表記)をprintします。

その後、自身の関数を呼ぶことにより、接続が終了するまで、データの受信を待機します。

実際にMinecraftと接続してみましょう。サーバーのIPアドレスを入力して、接続してみます。今回はポートにMinecraftのデフォルトポート25565を使用しているため、ポートの設定は必要ありません。

独自のポートを使用する場合は、サーバーアドレスに、

IPアドレス:ポート

というように、コロンで区切って入力します。

サーバーに接続すると、Minecraft上ではサーバーに接続中と表示された後変化はありませんが、サーバー上では、次のようなデータを受信したのがわかるはずです。(Minecraftのバージョン、サーバーのIPアドレス、ポートにより変化します)

�	localhostc�
10 00 fd 05 09 6c 6f 63 61 6c 68 6f 73 74 63 dd 02

場合によっては、このようになっていることもあります。

�	localhostc�[USERNAME][省略]
10 00 fd 05 09 6c 6f 63 61 6c 68 6f 73 74 63 dd 02 [省略]

[USERNAME]は、Minecraft上でのプレイヤー名です。

今はこのデータの内容の意味がわからないと思いますが、とりあえずMinecraftとのTCP通信が行えていることが確認できたら、次に進みましょう。

パケット

Minecraftとサーバーは、「パケット」と呼ばれるデータの区切りで通信を行います。パケットは、次のような形式を持ちます。

フィールド名データ型
Packet LengthVarInt
Packet IDVarInt
DataByte列
Packet Lengthは、このパケットの長さです。Packet IDとDataの合計バイト数を保持します。

Packet IDは、その名の通りこのPacketのIDです。このIDで、このパケットが、何に関するパケットなのかを区別します。Dataは、パケットの中身です。

VarIntというちょっと見慣れないデータ型があるのは置いておいて、先ほどの受信データを振り返ってみましょう。

10 00 fd 05 09 6c 6f 63 61 6c 68 6f 73 74 63 dd 02

まず、先頭にPacket Lengthがあります。ここでは、0x10がPacket Lengthです。十進数にすると、16です。つまり、この後の16バイトが、一つのパケットです。

数字の前に、0xがつくと、その後の数字は16進数となります。多くのプログラミング言語ではこの記述法で16進数を入力することができます。

let number = 0x10
print(number)

16

この記事では、16進数であることが文脈上明らかである場合、0xをつけずに16進数を表記する場合があります。ちなみに2進数は0bです。

続いて、その次のバイトの00はPacket IDです。そして、その後の15バイトはDataです。

VarInt,VarLong

さて、先ほどのパケットの構造で、Packet LengthとPacket IDは、VarIntというデータ型でした。これは、普通のIntと何が違うのでしょうか。

Intは、32bitの整数型です。つまり、Int型の変数は、常に4バイトのデータを持ちます。例えば、Intで16を表すには、

0x00 0x00 0x00 0x10
00000000 00000000 00000000 00010000

となります。(big endian)

これを見ると、小さい数字の時は、上位の3バイト分は使用されていないことがわかります。

VarIntとは、必要なバイトだけを使用する可変長型の変数です。これによって、小さい数字では、4バイトも使用せずに、Intを表現することができるのです。

https://wiki.vg/Protocol#VarInt_and_VarLong

次に、VarIntのルールを示します。

  • 一バイトを最小単位とする。
  • バイトの先頭のビットをその次のバイトがあるかどうかに使用し、残りの7bitを数字のエンコードに使用する。
  • 最下位バイトをはじめに置き、最上位バイトを最後に置く。(7bitのlittle endian)

big endianとlittle endianについて

big endianとlittle endianは、バイト列をメモリに格納する順番を示しています。

big endianでは、最上位のバイトを最初におきます。(十進数と同じように、ある16進数があるとき、左に行けば行くほど桁は大きくなります)

0x1111

4096,256,16,1の位

例えば、0x01234567をメモリにbig endianで格納する時、次のようになります。

0x01234567をbig endianで格納する時
//メモリアドレス
0x01 0x02 0x03 0x04

//値
0x01 0x23 0x45 0x67

一方、little endianは、最下位のバイトを最初におきます。

little endianで同じ値を格納すると、次のようになります。

0x01234567をlittle endianで格納する時
//メモリアドレス
0x01 0x02 0x03 0x04

//値
0x67 0x45 0x23 0x01

言葉で書くとわかりにくいため、実際にやってみたいと思います。

例えば、先ほどの16をVarIntで表してみましょう。

まず、16は最下位から数えると、5bitを使用します。VarIntは7bitを1バイトで表現できます。そのため、16は1バイトで表現できます。

よって、VarIntで、16は

0x10
00100000

と表せます。

続いて、1000をVarIntで表してみます。

1000は、Intでは

0x00 0x00 0x03 0xE8
00000000 00000000 00000011 11101000

と表せます。

1000は、10bitを使用します。

よって、VarIntは2バイトで表現できます。まず、最下位の7bitを取ります。

1101000

これに、次のバイトが存在することを示すbitを8bit目につけます。

11101000

そして、残りの3bitを配置します。

11101000 00000111

つまり、VarIntで1000は、

0xE8 0x07
11101000 00000111

と表せます。

次に、VarIntから数字を変換してみましょう。

80 04

このようなVarIntのデータがあるとします。まず、これを二進数に直してみましょう。

10000000 00000100

最上位の1bitを見ると、1バイト目は1,2バイト目は0になっています。つまり、このVarIntは2バイトであることがわかります。

次に、最上位の1bitを取り除いてみます。

0000000 0000100

7bitのlittle endianであるので、バイトを逆順に読み、

0000100 0000000

10桁の2進数となります。これを読んで、

0x02 0x00 

10進数だと、512となります。

以上が、VarIntの読み書きです。VarIntがInt(32bit)を可変長にしたものなので、数字は32bitまでしか代入できません。最上位のbitを含めて、VarIntは最大5バイトになります。

VarLongは、代入できる値を64bitに拡張したものです。仕組みは全く同じで、最上位のbitを含めて、VarLongは最大10バイトになります。

読み書きの実装

さて、先ほどの項から、VarIntとVarLongの読み書きはできるようになりましたが、実際に処理を行うのはコンピュータのため、プログラムとして実装する必要があります。

https://wiki.vg/Protocol#VarInt_and_VarLong

このサイトを参考にして、プログラムを書いてみます。

Read

まず、VarIntからIntへ変換できるようにしたいと思います。

   func readVarInt(from data: Data, offset: Int) -> (VarInt:Int32, position: Int) {
        var value:Int32 = 0
        var getbyte:UInt32 = 0
        var position = offset
        var inline_position = 0
        while true {
            let byte = data[position]
            getbyte |= UInt32((Int(byte) & 0x7F)) << inline_position
            position += 1
            inline_position += 7
            
            if (Int(byte) & 0x80) == 0 {
                break
            }
            if inline_position >= 32 {
                fatalError("VarInt is too big")
            }
        }
        if getbyte & 0x80000000 == 0x80000000 {
            value = Int32(~getbyte) * -1 - 1
        } else {
            value = Int32(getbyte)
        }
        return (value, position)
    }

ここでは、受信した全データを引数に取り、VarIntの始まるバイトを数字として指定します。そして、返り値として、Int32,position(VarIntの終了したバイトの数字)を得ます。

ここで、VarIntのバイトだけを読み込むのではなく、すべてのデータを読み込むのは、可変長であるためです。

詳しい仕組みを説明します。

まず、返り値として、value:Int32を用意し、読み込むバイトとして、positionにoffsetを指定します。ここで、SwiftではData型をUInt8の配列のように扱うことができます。つまり、Data[Int]で、指定したバイトを取り出すことができます。よって、offsetの数え方は、配列と同じく、一番初めのバイトを0と数えます。inline_positionは読み込んだバイトの合計です。

let byte = data[position]

まず、指定されたoffsetにあるバイトを読み込み、byteとします。

getbyte |= UInt32((Int(byte) & 0x7F)) << inline_position

次に、byteと7FをAND演算します。その後、左にinline_position桁ずらします。(2進数で)

7Fは二進数で1111111です。これをバイトが終わるまで繰り返すことで、数字を取り出すことができます。言葉だと説明が難しいので、動画にしてみました。

position += 1
inline_position += 7

読み込んだバイトとbitを保存しておきます。

if (Int(byte) & 0x80) == 0 {
   break
}
if inline_position >= 32 {
   fatalError("VarInt is too big")
}

次に、最上位のbitを確認し、0である場合にこのループを抜け出します。

また、inline_positionが32を超える場合、オーバーフローしてしまうため、エラーを出力します。

if getbyte & 0x80000000 == 0x80000000 {
   value = Int32(~getbyte) * -1 - 1
} else {
   value = Int32(getbyte)
}

最後に、VarIntは符号付き整数であり、正の数と負の数が存在します。負の表現は2の補数を使用して表現されるため、最後に符号をつけます。

return (value, position)

返り値として、value,positionを渡せば、read関数が出来上がります。

Write

次に、IntからVarIntに変換できるようにします。

    func writeVarInt(_ value: Int32) -> Data {
        var value = UInt32(bitPattern: value)
        var data = Data()
        while true {
            if (value & ~0x7F) == 0 {
                data.append(UInt8(value))
                break
            } else {
                data.append(UInt8(value & 0x7F | 0x80))
                value = value >> 7
            }
        }
        return data
    }

Int32を引数に取り、VarIntとなったDataを返します。

 var value = UInt32(bitPattern: value)
var data = Data()

まず、符号付き整数を、符号なし整数に変換します。これは絶対値を取るということではなく、2の補数を用いずに同じデータを10進数に直したものです。(必要ないかもしれないです)

また、VarIntとなるDataを初期化します。

while true {
   if (value & ~0x7F) == 0 {
   data.append(UInt8(value))
   break
   } else {
      data.append(UInt8(value & 0x7F | 0x80))
      value = value >> 7
   }
}

Swiftでは、数字に~をつけると、NOT演算を行います。そのため、まず、入力された数字が7bitより大きいかどうかを判断し、小さい場合dataにはそのままvalueを入れて終了します。

それより大きい場合、7bitのみを取り出し、最上位に1を付加してdataに加えます。そして、valueを7bit右にずらします。(先ほど取り出した7bitが消える)

これを繰り返すことで、VarIntへ変換することができます。

return data

最後に返り値として、dataを渡します。

これでVarIntの読み書きができるようになりました。ついでにVarLongの読み書きもします。桁の制限と変数の型が異なるだけで処理は同じです。

VarLongの読み書き

func readVarLong(from data: Data, offset: Int) -> (VarLong:Int64, position: Int) {
        var value:Int64 = 0
        var position = offset
        var inline_position = 0
        
        while true {
            let byte = data[position]
            value |= Int64((Int(byte) & 0x7F)) << inline_position
            position += 1
            inline_position += 7
            
            if (Int(byte) & 0x80) == 0 {
                break
            }
            if inline_position >= 64 {
                fatalError("VarLong is too big")
            }
        }
        return (value, position)
    }
    
    func writeVarLong(_ value: Int64) -> Data {
        var value = UInt64(bitPattern: value)
        var data = Data()
        while true {
            if (value & ~0x7F) == 0 {
                data.append(UInt8(value))
                break
            } else {
                data.append(UInt8(value & 0x7F | 0x80))
                value = value >> 7
            }
        }
        return data
    }

まとめ

今回は、MinecraftのTCP Listenerの作成、VarInt,VarLongの読み書きを行いました。次回はパケットのDataを読んでいきたいと思います。

気になるアイテム



COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です