5 minute read

카일레라란?

Kaillera enables emulators to play on the Internet. With Kaillera you can enjoy playing video games with others from all over the world. it consists of a client and a server. The client is usually embedded into your favorite emulator and the server is a stand-alone application that needs to be run on a machine directly wired to the Internet.

Kaillera 는 에뮬레이터가 인터넷을 통해 같이 넷플레이를 하게끔 도와주는 서버/클라이언트 프로토콜의 지칭한다.

기본적으로 UDP 프로토콜을 쓰고 있으며, UDP 프로토콜 특성상 패킷 누락과 같은 현상을 보완하기 위해 서버/클라이언트쪽에서 항상 최근 패킷2개를 같이 던져준다.

예를 들어 패킷 하나를 들고오면

0000   03 07 00 04 00 0b 00 ff ff 06 00 09 00 08 00 2e
0010   2e 2e 2e 2e 2e 00 05 00 0e 00 0c 00 00 00 00 00
0020   00 00 00 00 00 ff ff 02
Kaillera Protocol
    Messages: 3 -- 1 byte
    Message Seqeunce: 7 -- 2 bytes
    len(message): 4 -- 2 bytes
    Message Type: 0x0b -- 1 byte
    Message Seqeunce: 6
    len(message): 9
    Message Type: 0x08
    Message Seqeunce: 5
    len(message): 14
    Message Type: 0x0c

위와 같이 몇 개의 Messages 를 전송하는지 명시 하고 그 개수만큼 보내는 쪽이 최근 보낸 패킷들을 기억해서 전, 전전 패킷을 같이 보내준다.

그래서 서버/클라이언트는 항상 누락된 패킷에 대한 복구 로직이 있어야 하며, 만약 누락이 된다면 게임 갈림 현상이 일어난다.

특이한 게임 입력 동기화 시스템

두명의 플레이어가 게임을 하는 모습을 상상해보자.

어떻게 동기화할것인가? 일단 Kaillera 는 모든 참여자가 입력데이터를 전달해주면 서버가 입력 데이터를 취합하여 참여자들에게 입력값을 브로드캐스팅한다.

P1 -> Server: A 키
P2 -> Server: 아무키도 안누름
Server -> P1: 키입력정보
Server -> P2: 키입력정보

여기서 Kaillera 는 입력값에 대한 전송 바이트를 아끼기 위해 GameData, GameCache 개념을 사용한다.

GameData, GameCache

GameData 는 실제 입력에 대한 데이터이고 GameCache 는 캐싱된 데이터에 대한 slot 위치를 다루는 방법이다.

Kaillera 는 매 프레임 마다 입력값을 서버로 보내서 서버가 클라이언트들과 입력값을 동기화 시키면서 서로 게임을 할 수 있게 한다. 커넥션타입에 따라 GameData 의 길이는 달라지는데, 일단 문제를 단순화시키기 위해 2 bytes 라 가정한다.

# GameData 프로토콜
P1 -> Server: 0x03 0x00
P2 -> Server: 0x00 0x00
Server -> P1: 0x03 0x00 0x00 0x00
Server -> P2: 0x03 0x00 0x00 0x00

GameData 프로토콜은 위와 같이 입력값을 그대로 붙여서 서버쪽에서 반환한다. 너무 간단해서 더 설명할게 없다.

플레이어가 두명이니까 2 bytes 를 2 번 받아서 4 bytes 를 응답한다.

하지만 그 다음 프레임에서 P1 가 0x03 0x00 입력을 전송하기 위해 0x03 0x00 를 전송하지 않는다.

이 때 GameCache 를 전송한다. 입력 데이터가 들어올 때, 클라이언트는 0x03 0x00 에 대한 slot 을 할당한다. 예를 들어 1 번에 할당했다 하자.

그러면

# GameCache 프로토콜
P1 -> Server(GameCache): 0x01 # 클라이언트가 등록한 슬롯 번호
P2 -> Server(GameData): 0x04 0x00 # 새 입력 0x04 0x00 에 대해 GameData
Server -> P1(GameData): 0x03 0x00 0x04 0x00
Server -> P2(GameData): 0x03 0x00 0x04 0x00

P1 -> Server(GameCache): 0x01 # P1 클라이언트가 등록한 슬롯 번호
P2 -> Server(GameCache): 0x01 # P2 클라이언트가 등록한 슬롯 번호
Server -> P1(GameCache): 0x02 # 서버가 등록한 슬롯 번호, 0x03 0x00 0x04 0x00 가 아님
Server -> P2(GameCache): 0x02 # 서버가 등록한 슬롯 번호, 0x03 0x00 0x04 0x00 가 아님

위와 같이 통신을 하게 된다. 반복 되는 입력에 대해 서버/클라이언트가 각각 slot 을 관리하고 있어서 slot 번호만 보내줘도 어떤 입력을 보냈는지 서로 안다.

서버는 P1, P2 입력을 가지고 실제 어떤 GameData (4 bytes) 를 응답할지 복원하고, 이 데이터가 slot 에 있다면 slot 번호를 보내고(GameCache), 만약 보낸 적이 없는 데이터라면 GameData(4 bytes) 를 보낸다.

예를 들어 설명하면

지연된 입력처리 방법

캐시에 대한 이야기는 여기서 하면 복잡하여 최대한 단순하게 풀어서 설명한다.

접속 타입에 따라 얼만큼 입력을 지연시킬것인지 플레이어는 정할 수 있다. 예를 들어
유저 A 는 접속 타입: 1 (1만큼 지연, 지연 없음)
유저 B 는 접속 타입: 2 (2만큼 지연)

유저 A 는 접속 타입이 1 이므로 키입력을 서버에 1개만 줘도, 유저A, 유저B 에 대한 키입력 데이터를 받을 수 있다. 유저 B 는 접속 타입이 2 이므로 키입력을 2개를 줘야 유저A, 유저B 에 대한 키입력을 받을 수 있다.

이게 가능하려면 서버에서 많은 일을 해야하는데,

먼저 서버에서는 각 유저는 나머지 유저(자신을 포함)의 키입력을 다 가지고 있어야 한다.

유저A: 
 - 01 02 03 04 
유저B:
 - 05 06 07 08
입력을 서버에게 주는 상황이라면
서버는 유저A 에게 01 02 05 06 을 보낸 후에 03 04 07 08 을 전송한다.
유저B 에게는 01 02 05 06 03 04 07 08 을 전송한다. (접속 타입에 따라 지연 됐으므로)

서버는 매번 01 02 05 06 03 04 07 08 이런 데이터를 주기엔 패킷 낭비가 심해서 전송할 때도 앞에서 설명했던 캐시 시스템을 쓴다. 만약 만들어낸 패킷이 새로운 패킷 배열이라면 slot index 를 증가시킨 후에 거기에 캐싱한다.

카일레라의 Game Cache 좀 더 자세히…

카일레라의 Game Cache 의 slot index 는 [0, 255] 값을 가진다. 실제로 서버를 구현해보면 이 캐시의 슬롯이 생각보다 빨리 차는 것을 볼 수 있다. 슬롯이 꽉 차면 제일 오래된 슬롯을 없애고 당긴다. 예를 들면

[A,B,C,D,E, ..., Y] # cache is full
new input: Z
[B,C,D,E, ..., Y, Z]

새로운 입력 Z 가 들어오면 제일 오래된 캐시 A 가 없어지고 255 자리에 Z 가 들어간다. 한번이라도 꽉 차면 매번 새로운 입력값이 올 때 마다 모든 slot 의 값이 바뀐다.

개인적으로 circular buffer 처럼

[Z, A, B,C,D,E, ..., Y]

위와 같은 정책을 가져가는게 어땠을까 한다. 어쨌든 255 개의 슬롯의 위치를 바꾸는건 부담스러운일이다.

kaillera wireshark dissector

$ cat ~/.config/wireshark/plugins/kaillera.lua


local proto_kaillera = Proto.new("kaillera", "Kaillera Protocol")

local messages = ProtoField.uint8("kaillera.messages", "Messages", base.DEC)
local msg_seq = ProtoField.uint16("kaillera.msg_seq", "Message Seqeunce", base.DEC)
local message_length = ProtoField.uint16("kaillera.message_length", "len(message)", base.DEC)
local message_type = ProtoField.uint8("kaillera.message_type", "Message TYPE", base.HEX)

proto_kaillera.fields = { messages, msg_seq, message_length, message_type }

function proto_kaillera.dissector(buffer, pinfo, tree)
    subtree = tree:add(proto_kaillera, buffer(0))

    local info = ""
    subtree:add(messages, buffer(0, 1))
    info = info .. "messages: " .. buffer(0, 1):uint() .. ", "
    local index = 1
    local i = 0
    local comma = ""
    -- local messageNum = buffer(0, 1):uint32()
    for i = 0, buffer(0, 1):uint() - 1 do
        subtree:add_le(msg_seq, buffer(index, 2))
        info = string.format("%s%s [seq: %d, ", info, comma, buffer(index, 2):le_uint())
        index = index + 2
        subtree:add_le(message_length, buffer(index, 2))
        local messageLength = buffer(index, 2):le_uint()
        index = index + 2
        subtree:add(message_type, buffer(index, 1))
        info = string.format("%s message_type: %x]", info, buffer(index, 1):uint())
        index = index + 1
        index = index + messageLength - 1
        comma = ", "
    end

    local ml = buffer(3, 2):le_uint()
    pinfo.cols.info = info .. " / " .. "[" .. pinfo.src_port .. "->" .. pinfo.dst_port .. "]"
end

local function heuristic_checker(buffer, pinfo, tree)
    proto_kaillera.dissector(buffer, pinfo, tree)
end

proto_kaillera:register_heuristic("udp", heuristic_checker)

서버 구현체

https://github.com/hsnks100/direlera-rs

Updated: