7 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 특성상 패킷 누락을 보완하기 위해 서버/클라이언트는 최근 패킷들을 함께 전송한다.

예를 들어 패킷 하나를 캡처하면:

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 Sequence: 7 -- 2 bytes
    len(message): 4 -- 2 bytes
    Message Type: 0x0b -- 1 byte
    Message Sequence: 6
    len(message): 9
    Message Type: 0x08
    Message Sequence: 5
    len(message): 14
    Message Type: 0x0c

위와 같이 몇 개의 Messages를 전송하는지 명시하고, 그 개수만큼 최근 보낸 패킷들을 함께 전송한다.

서버/클라이언트는 항상 누락된 패킷에 대한 복구 로직을 가져야 하며, 누락이 발생하면 게임 갈림 현상이 발생한다.

참고: 이 문서는 공식 Kaillera 프로토콜 문서에서 설명하지 않는 실제 동작 메커니즘을 Wireshark 패킷 분석과 역공학을 통해 발견한 내용을 담고 있다. 특히 플레이어별 캐싱, 프레임 인터리빙, 다중 지연 동기화 등의 핵심 메커니즘은 공식 문서에 없는 내용이다.

게임 입력 동기화 시스템

Kaillera는 모든 참여자가 입력 데이터를 전달하면, 서버가 입력을 취합하여 모든 참여자에게 브로드캐스팅한다.

P1 -> Server: A 키
P2 -> Server: 아무키도 안 누름
Server -> P1: [P1의 입력][P2의 입력]
Server -> P2: [P1의 입력][P2의 입력]

Kaillera는 대역폭을 절약하기 위해 GameDataGameCache 개념을 사용한다.

패킷 포맷

Game Data (0x12)

클라이언트 → 서버:

+------+------+-------------------+
| 0x12 | 0x00 | Input Data        |
+------+------+-------------------+
  1B     1B     N bytes (N = delay × 2)

서버 → 클라이언트:

+------+------+----------------------------+
| 0x12 | 0x00 | Combined Data              |
+------+------+----------------------------+
  1B     1B     player_count × delay × 2 bytes

Game Cache (0x13)

클라이언트 → 서버:

+------+------+----------+
| 0x13 | 0x00 | Position |
+------+------+----------+
  1B     1B       1B

서버 → 클라이언트:

+------+------+----------+
| 0x13 | 0x00 | Position |
+------+------+----------+
  1B     1B       1B

GameData와 GameCache

GameData는 실제 입력 데이터이고, GameCache는 캐싱된 데이터의 슬롯 위치를 참조하는 방식이다.

Kaillera는 매 프레임마다 입력값을 서버로 전송하여 클라이언트들과 동기화한다. 기본적으로 각 프레임의 입력은 2바이트이다.

기본 예제

# GameData 전송
P1 -> Server: [0x12][0x00][0x03][0x00]
P2 -> Server: [0x12][0x00][0x00][0x00]
Server -> P1: [0x12][0x00][0x03][0x00][0x00][0x00]
Server -> P2: [0x12][0x00][0x03][0x00][0x00][0x00]

플레이어가 2명이므로 각 2바이트씩 받아서 4바이트를 응답한다.

다음 프레임에서 P1이 동일한 입력 0x03 0x00을 보낼 때, 실제 데이터를 보내지 않고 GameCache를 사용한다.

클라이언트는 보낸 입력 0x03 0x00에 대한 슬롯을 할당한다. 예를 들어 슬롯 1번에 할당했다면:

# GameCache 사용
P1 -> Server: [0x13][0x00][0x01]  # 클라이언트 캐시 슬롯 번호
P2 -> Server: [0x12][0x00][0x04][0x00]  # 새 입력
Server -> P1: [0x12][0x00][0x03][0x00][0x04][0x00]
Server -> P2: [0x12][0x00][0x03][0x00][0x04][0x00]

# 다음 프레임
P1 -> Server: [0x13][0x00][0x01]  # P1 캐시 슬롯
P2 -> Server: [0x13][0x00][0x01]  # P2 캐시 슬롯
Server -> P1: [0x13][0x00][0x02]  # 서버 캐시 슬롯 (0x03 0x00 0x04 0x00)
Server -> P2: [0x13][0x00][0x02]  # 서버 캐시 슬롯

반복되는 입력에 대해 서버/클라이언트가 각각 슬롯을 관리하므로, 슬롯 번호만 전송해도 어떤 입력인지 알 수 있다.

캐시 시스템 상세

아키텍처

  • 256개의 FIFO 캐시 슬롯 (위치 0-255, circular buffer로 순환)
  • 클라이언트 입력 캐시: 클라이언트가 보낸 입력을 저장
  • 서버 출력 캐시 (플레이어별): 각 플레이어마다 별도로 관리되는 결합 데이터 캐시

캐시 동작

전송 시:

IF 현재 데이터가 캐시의 위치 P에 존재:
    Game Cache(P) 전송
ELSE:
    Game Data(data) 전송
    cache[next_position] = data
    next_position = (next_position + 1) % 256

수신 시:

data = cache[received_position]
데이터 처리

플레이어별 서버 캐시 (중요!)

서버는 각 플레이어마다 별도의 출력 캐시를 유지한다.

예제:

프레임 1: P0이 [A1 A2 B1 B2]를 수신 → P0의 캐시 위치 5번

프레임 5: 서버가 [A1 A2 B1 B2]를 다시 전송할 때
  → P0: Game Cache(5)  (P0은 이미 본 데이터)
  → P1: Game Data([A1 A2 B1 B2])  (P1은 처음 보는 데이터)

캐시 슬롯 관리

Game Cache의 슬롯 인덱스는 0-255 범위를 가진다.

초기 상태: [A, B, C, D, E, ..., Y, -]
next_position: 25

새 입력 Z:
상태: [A, B, C, D, E, ..., Y, Z]
next_position: 26

슬롯이 꽉 찼을 때 새 입력 AA:
상태: [AA, B, C, D, E, ..., Y, Z]  (0번 위치에 덮어쓰기)
next_position: 1

circular buffer처럼 next_position만 증가시키며 가장 오래된 슬롯에 덮어쓴다. 이는 O(1) 작업이므로 효율적이다.

플레이어 지연(Delay)

정의

플레이어는 접속 타입에 따라 프레임 지연을 설정할 수 있다:

Delay 전송 주기 (60fps) 입력 크기
1 매 프레임 (~16.7ms) 2바이트
2 2 프레임마다 (~33.3ms) 4바이트
3 3 프레임마다 (~50ms) 6바이트
N N 프레임마다 N × 2바이트

다중 프레임 입력

Delay가 2인 플레이어는 2개의 프레임 입력을 한 번에 전송한다:

Delay 2 플레이어 전송: [0x12][0x00][0xAA][0xBB][0xCC][0xDD]
                                     ├─프레임 N─┤ ├─프레임 N+1┤

서버는 이를 개별 2바이트 프레임으로 분리하여 처리한다.

프레임 동기화

핵심 규칙

서버는 모든 플레이어가 프레임 N의 입력을 제공할 때까지 프레임 N을 배포할 수 없다.

예제: 2명의 플레이어, 다른 Delay

설정:
  P0: delay=1 (매 프레임 전송)
  P1: delay=2 (2 프레임마다 전송)

타임라인:

시간 0ms:
  P0이 프레임 1 전송
  P1이 프레임 1-2 전송
  → 서버가 프레임 1 배포: [P0_F1][P1_F1]

시간 16ms:
  P0이 프레임 2 전송
  P1은 대기 (이미 프레임 1-2 전송함)
  → 서버가 프레임 2 배포: [P0_F2][P1_F2]

시간 33ms:
  P0이 프레임 3 전송
  P1이 프레임 3-4 전송
  → 서버가 프레임 3 배포: [P0_F3][P1_F3]

블로킹

P0: 프레임 5 ✓
P1: 프레임 5 ✓
P2: 프레임 5 ✗ (누락)

→ 서버는 대기
→ P2의 입력이 도착할 때까지 배포하지 않음

이것이 지연이 큰 플레이어 한 명 때문에 모든 플레이어가 느려지는 이유다.

Preemptive Padding (선행 패딩)

공식

padding_frames = player_delay - minimum_delay_in_game

초기화

게임 시작 시, 느린 플레이어의 입력 큐는 [0x00, 0x00] 프레임으로 미리 채워진다.

예제: P0 (delay=1), P1 (delay=2), P2 (delay=3)

초기 상태:
  P0 큐: []                      (가장 빠름, 패딩 없음)
  P1 큐: [[00 00]]               (1 프레임 패딩)
  P2 큐: [[00 00][00 00]]        (2 프레임 패딩)

첫 입력 후:
  P0이 [AA BB] 전송
  P1이 [CC DD][EE FF] 전송
  P2이 [11 22][33 44][55 66] 전송

큐 상태:
  P0: [[AA BB]]
  P1: [[00 00][CC DD][EE FF]]
  P2: [[00 00][00 00][11 22][33 44][55 66]]

프레임 1 배포: [AA BB][00 00][00 00]
프레임 2 배포: [다음P0][CC DD][00 00]
프레임 3 배포: [다음P0][EE FF][11 22]

이 메커니즘은 서로 다른 지연을 가진 플레이어들이 동시에 게임을 시작할 수 있게 한다.

프레임 배포 스케줄

각 플레이어는 자신의 delay 비율에 따라 결합 데이터를 수신한다:

시간 0ms:
  프레임 1 준비
  → P0 (delay=1): [프레임_1]
  → P1 (delay=2): 대기

시간 16ms:
  프레임 2 준비
  → P0: [프레임_2]
  → P1: [프레임_1][프레임_2]  (한 번에 2 프레임)

시간 33ms:
  프레임 3 준비
  → P0: [프레임_3]
  → P1: 대기

시간 50ms:
  프레임 4 준비
  → P0: [프레임_4]
  → P1: [프레임_3][프레임_4]

프레임 인터리빙 (매우 중요!)

입력은 프레임 단위로 인터리빙되어야 하며, 플레이어별로 연결되어서는 안 된다.

잘못된 방식:

P0: [01 00][02 00]
P1: [AA 00][BB 00]

결합: [01 00][02 00][AA 00][BB 00]  ✗
      └──모든 P0──┘ └──모든 P1──┘

올바른 방식:

P0: [01 00][02 00]
P1: [AA 00][BB 00]

결합: [01 00][AA 00][02 00][BB 00]  ✓
      └─프레임 1──┘ └─프레임 2──┘

알고리즘:

FOR 각 프레임 F in (0..frame_count):
    FOR 각 플레이어 P in (0..player_count):
        결합_데이터에 player[P].frame[F] 추가

예제 (3 플레이어, 2 프레임):

입력:
  P0: [A1 A2][A3 A4]
  P1: [B1 B2][B3 B4]
  P2: [C1 C2][C3 C4]

출력:
  [A1 A2][B1 B2][C1 C2][A3 A4][B3 B4][C3 C4]
   └────프레임 0──────┘ └────프레임 1──────┘

이 방식은 각 프레임의 모든 플레이어 입력이 함께 있어야 하는 게임 로직의 요구사항을 충족시킨다.

시퀀스 다이어그램

정상 동작

클라이언트 0 (delay=1)      서버               클라이언트 1 (delay=1)
      |                       |                         |
      | GD [01 00]           |                         |
      |--------------------->|                         |
      |                       | GD [02 00]             |
      |                       |<------------------------|
      |                       |                         |
      |                   [결합]                        |
      |                       |                         |
      | GD [01 00 02 00]     |                         |
      |<---------------------|                         |
      |                       | GD [01 00 02 00]       |
      |                       |------------------------>|

캐시 히트

클라이언트 0                서버               클라이언트 1
      |                       |                         |
      | GD [AA BB]           |                         |
      |--------------------->|                         |
      |                       | GD [CC DD]             |
      |                       |<------------------------|
      | GD [AA BB CC DD] (캐시 위치 0)                 |
      |<------------------------------------------------|
      |                       |                         |
      | GC(0) [AA BB]        |                         |
      |--------------------->|                         |
      |                       | GC(0) [CC DD]          |
      |                       |<------------------------|
      | GC(0) [AA BB CC DD]  |                         |
      |<------------------------------------------------|

다른 Delay

클라이언트 0 (delay=1)      서버               클라이언트 1 (delay=2)
      |                       |                         |
      | GD [01 00]           |                         |
      |--------------------->|                         |
      |                       | GD [AA BB CC DD]       |
      |                       |<------------------------|
      |                       |                         |
      | GD [01 00 AA BB]     |                         |
      |<---------------------|                         |
      | GD [02 00]           |                         |
      |--------------------->|                         |
      | GD [02 00 CC DD]     |                         |
      |<---------------------|                         |
      |                       | GD [01 00 AA BB]       |
      |                       |      [02 00 CC DD]     |
      |                       |------------------------>|

Game Cache가 새로운 조합 생성

Game Cache를 사용해도 각 플레이어의 입력 조합이 달라지면 새로운 데이터가 생성된다:

프레임 1: P0=[AA BB], P1=[CC DD] → [AA BB CC DD] (캐시 위치 0)
프레임 2: P0이 GC(0) [AA BB] 전송, P1=[EE FF] → [AA BB EE FF] (새 데이터!)

클라이언트 0                서버               클라이언트 1
      |                       |                         |
      | GD [AA BB]           |                         |
      |--------------------->|                         |
      |                       | GD [CC DD]             |
      |                       |<------------------------|
      | GD [AA BB CC DD]     |                         |
      |<---------------------|                         |
      |                       | GD [AA BB CC DD]       |
      |                       |------------------------>|
      |                       |                         |
      | GC(0) [AA BB]        |                         |
      |--------------------->|                         |
      |                       | GD [EE FF]             |
      |                       |<------------------------|
      | GD [AA BB EE FF]     |                         |
      |<---------------------|                         |
      |                       | GD [AA BB EE FF]       |
      |                       |------------------------>|

클라이언트가 캐시를 재사용해도, 다른 플레이어의 입력이 다르면 서버는 새로운 조합을 생성해야 한다.

서버 구현체

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