- Witchcraft는 Bash만으로 Minecraft 서버를 처음부터 구현한 실험 프로젝트로, 바이너리 프로토콜과 Join Game, 청크 전송까지 다뤄 실제 클라이언트 접속을 목표로 함
- Bash가 널 바이트를 문자열에 보존하지 못하는 한계는 바이너리를 변수에 담지 않고
dd와xxd파이프 안에서 처리하는 방식으로 우회함 - 구현 난점은 VarInt/VarLong, IEEE 754 부동소수점, Position, NBT 같은 Minecraft 고유 데이터 형식에 집중됐고, 특히 Double 변환은 외부 명령 호출 비용 때문에 느렸음
- 서버는 Server List Ping, handshake, Join Game, Chunk Data And Update Light 패킷 순으로 확장됐으며, Dimension codec은 vanilla 서버에서 가져온 NBT 바이너리 blob을 사용함
- 훅 기반 플러그인으로 월드 생성과 효과를 바꿀 수 있지만, 멀티플레이 미완성,
/dev/shm/witchcraft기반 스레드 통신, 느린 데이터 교환, BusyBox 1.35.0 의존성이 남아 있음
Bash에서 바이너리 Minecraft 프로토콜 다루기
- 초기 시도는 2009년 Classic protocol을 대상으로 했지만, Bash로 바이너리 데이터를 제대로 파싱하기 어렵다는 한계가 먼저 드러남
- Bash 문자열은 널 바이트(0x00) 를 무시하고, 널 바이트 발생 여부를 감지할 방법도 없어 엄격한 바이너리 프로토콜에서는 데이터가 손상될 수 있음
- 우회 방식은 바이너리 데이터를 Bash 변수나 명령 치환에 넣지 않고 파이프 안에 유지하는 것임
dd count=$len bs=1 status=none | xxd -p로 필요한 바이트 수를 읽고 hex 문자열로 변환함- hex 문자열 위에서 패턴 매칭, 치환, 데이터 추출을 수행함
- 응답 전송은
xxd의 reverse 옵션으로 다시 바이너리로 바꿈
- Minecraft 기본 TCP 포트에서 연결을 받기 위해
ncat을 사용하고, 연결이 들어오면 메인 셸 스크립트mc.sh를 실행함
프로토콜 데이터 타입 구현
- 가장 먼저 구현하기 좋은 대상은 Server List Ping 패킷임
- 필수 패킷은 아니어서 서버가 제대로 응답하지 않아도 게임 접속 자체는 가능함
- 다만 data types 같은 핵심 프로토콜 개념을 익히기 쉬움
-
VarInt와 VarLong
- Minecraft의 VarInt/VarLong은 MQTT 경험자에게 익숙할 수 있는 LEB128 변형임
- LEB128은 바이트를 1개의 신호 비트와 7개의 데이터 비트로 나눠 정수 길이를 저장하는 압축 방식임
- 첫 비트가 0이면 해당 바이트가 마지막이고, 1이면 다음 바이트가 이어짐
- Bash로 reference implementation을 그대로 옮기기 어려워 modulo와 division을 이용한 자체 인코더를 작성함
- 디코더는 AND 연산과 곱셈을 이용해 reference 방식과 비슷하게 구현함
- LEB128 자체보다 일반 int, long, signed short와 섞여 프로토콜 곳곳에 등장하는 점이 더 번거로웠음
-
IEEE 754 부동소수점
- IEEE 754 Double 변환은 구현에서 가장 성가신 부분 중 하나였음
- 기본 구현에는 음수 거듭제곱을 적용하는 루프가 필요하지만, Bash는 음수 거듭제곱을 기본 지원하지 않음
- Perl은 피했고,
bc는 사용 환경이나 BusyBox 버전에서 거듭제곱을 지원하지 않는 것으로 보였음 awk는2**-1같은 음수 거듭제곱을 처리할 수 있어 변환 구현에 사용됨- 초기 구현은 Player Move 패킷에서 클라이언트가 보내는 약 50~100개 패킷과 각 패킷의 X·Y·Z Double 3개를 처리하는 데 여러 분이 걸릴 정도로 느렸음
- Bash
for루프 안에서 반복적으로awk를 호출하던 구조를awk내부 루프로 옮겨 외부 명령 호출 수를 줄임 - 이후 변환은 Xeon E5-2680v2에서 약 10ms 수준까지 줄어듦
- 이전 버전의 약 350ms 수치는 확실한 측정값은 아님
-
Position 데이터 타입
- Position은 Mojang이 만든 64-bit Long 기반 데이터 타입임
- X는 상위 26비트, Z는 중간 26비트, Y는 하위 12비트에 저장됨
- Bash에는 필요한 bitshift 연산자가 있어 구현은 쉬웠음
- 다만 이 타입은 많이 쓰이지 않으며, 많은 패킷은 X·Y·Z 좌표를 별도 Double 값으로 저장함
- 그 결과 위치 데이터가 패킷당 64비트에서 192비트로 커짐
- 기본 world border인 30,000,000까지의 숫자만 필요하다고 가정하면 9자리 부동소수점 정확도를 얻게 됨
- 일반 서버 통신은 zlib을 사용하고, 블록 안 위치 표현에는 현실적으로 소수점 2~3자리 이상이 필요하지 않다고 봄
-
NBT
- NBT는 Mojang 내부 형식이며, 바이너리 데이터를 위한 JSON과 비슷한 형식임
- JSON처럼 명세 밖의 임의 데이터 저장에도 사용됨
- Mojang은 예를 들어 가변 길이 bitstream을 Long 배열로 저장함
- 배열이 Long 또는 byte 정렬이 아니면 마지막 Long은 0으로 padding됨
- NBT 파서는 거의 완성한 적이 있었지만, 끝까지 마무리할 가치가 없다고 판단함
- 해당 코드는 프로젝트 디렉터리로
tmpfs를 많이 사용하던 중 시스템 crash로 유실됨
접속 가능한 서버 만들기
- Server Ping 다음 단계는 실제 게임 접속에 필요한 handshake와 추가 패킷 처리였음
- 클라이언트가 서버에 들어오려면 handshake를 마치고 chunk, player position, inventory, join game 관련 패킷을 받아야 함
- 가장 큰 장애물은 Join Game 패킷과 Chunk 패킷 내부 데이터 구조였음
-
Join Game
- Join Game 패킷은 초기 메타데이터를 전송함
- 포함 항목은 플레이어 entity ID, gamemode, 월드 관련 정보, Minecraft 1.16 전후부터 들어간 Dimension codec임
- Dimension codec은 NBT Compound라 구현 부담이 컸음
- Witchcraft는 이 NBT 필드를 vanilla 서버에서 가져와 사용함
- 이 부분은 구현에서 유일한 바이너리 blob이며, 재구현은 가능하지만 커스터마이즈할 필요가 없다고 판단함
-
Chunk Data And Update Light
- Chunk Data And Update Light 패킷은 처음에는 크고 복잡해 보이지만, 여러 BitSet 필드를
0x00으로 두고 Block Entity 필드를 보내지 않으면 단순해짐 - 남는 필드는 X, Y, heightmaps, Data 필드임
- heightmaps는
b000000010반복을 더 복잡하게 인코딩한 형태이며, 사실상 어떤 값이어도 될 수 있다고 봄
- Chunk Data And Update Light 패킷은 처음에는 크고 복잡해 보이지만, 여러 BitSet 필드를
Chunk Section과 palette 처리
- Data 필드는 Chunk Section 배열임
- Chunk Section은 16×16×16 블록이며, 여러 section을 쌓아 하나의 Chunk를 만들 수 있음
- 구현 단순화를 위해 이 배열은 단일 요소만 사용함
- Chunk Section은 block count, block states container, biome container로 구성됨
- block states와 biome container는 palette 구조를 사용함
- 실제 블록 데이터 앞에서 서버가 local block ID와 global block ID의 매핑을 정의해야 함
- 가능한 한 많은 데이터를 작은 공간에 넣기 위한 구조임
- 블록 정의는 최소 4비트까지 작아질 수 있음
- Witchcraft는 관리 편의상 최소 4비트 대신 8 bits per block을 사용함
- 사용 가능한 palette entry가 256개가 됨
- 실제 chunk 데이터는 palette entry를 가리키는 hex 숫자를 보내면 됨
- 4비트 palette도 hex 문자열에서 한 바이트가 두 블록을 표현할 수 있어 다루기 쉽지만, chunk당 16개 블록으로 제한됨
- 표준은 4 bits per block부터 9 bits per block까지 허용하고, 그 외에는 15 bits per block direct palette mapping으로 간주함
- biome palette는 별도 방식으로 처리됨
- 빈 palette를 보내고 biome ID
0x01, 즉minecraft:plains를 chunk 안 모든 region에 직접 매핑함 - vanilla 동작을 역공학한 결과에 기반함
- 해당 패킷 부분의 기존 문서가 부정확할 수 있다고 의심함
- 빈 palette를 보내고 biome ID
훅 기반 플러그인 구조와 데모
- 기본 구현만으로는 평범한 chunk만 표시되기 때문에, 서버가 chunk 표시 이상의 동작을 할 수 있음을 보여줄 데모가 필요했음
- 데모마다 별도 소스 트리를 만들지 않기 위해 override 가능한 함수들을 hooks라고 부르고, 서버가 사용자 코드를 로드할 수 있게 함
- 이 구조로 월드 모양 변경부터
pkt_effect를 연결해 마우스를 움직일 때 플레이어가 ticking noise를 내게 하는 동작까지 구현할 수 있음 - 예시 플러그인은 기본 palette에서 무작위 블록을 골라 chunk를 생성함
hook_chunks()에서chunk_header를 호출함- 4096개 블록에 대해
RANDOM%30값을 hex로 추가함 - 생성한 chunk를
$TEMP/world/0000000000000000에 저장함 - 주변 좌표에
pkt_chunk를 여러 번 호출해 chunk를 전송함
- 또 다른 데모인 digmeout은 점수 기반 간단한 게임임
- 플레이어를 무작위로 배치된 돌과 광물이 있는 chunk에 던짐
- 제한 시간이 끝날 때까지 가장 가치 있는 광물을 캐는 방식임
Witchcraft의 제약
- Bash는 십진 소수 처리에 매우 약함
- Integer는 어느 정도 처리할 수 있음
- 십진 소수는 입력에서 곱하고 출력에서 적절한 위치에 점을 넣는 방식으로 다뤄야 함
- 이 때문에 Witchcraft가 처리하는 대부분 또는 모든 숫자는 int임
- 멀티플레이는 제대로 작동하지 않음
- 어느 정도 동작은 하지만, 완성하거나 다듬는 데 시간을 들이지 않았음
- Witchcraft는 기술적으로 multi-threaded server임
- 그 결과 스레드 간 통신을 위해 좋지 않은 hack이 필요함
- 대부분의 global data는
/dev/shm/witchcraft아래 저장되며, 내부적으로$TEMP로 참조됨
- 성능은 큰 제약으로 남아 있음
- 특히 여러 thread 사이의 데이터 교환이 느림
- 많은 양의 데이터를 보내기는 어렵고, solid chunk 16개를 생성해 보내는 데 최대 1초가 걸릴 수 있음
- 현재는 최신 BusyBox 1.35.0이 설치된 경우에만 실행됨
- GNU coreutils로는 테스트하지 않았고, 작동하지 않을 것으로 예상함