네트워크 프로토콜

게임과 게임 서버가 주고받는 메시지예요. 봇, 서버 목록 사이트, 다른 클라이언트처럼 게임 밖에서 서버에 붙는 프로그램을 만들 때 보세요. 서버의 규칙이나 콘텐츠를 바꾸려면 플러그인을 보세요.

  1. 연결
  2. 서버 정보 (info)
  3. 접속 (join)
    1. 파일 받기
  4. 행동 (act)
  5. 상태
  6. 서버가 보내는 것
  7. 그 밖의 클라이언트 메시지
    1. 친구
  8. 데이터그램
    1. 클라이언트 → 서버
    2. 서버 → 클라이언트
  9. 청크
  10. 관리 (manage)

서버는 모든 행동을 스스로 검사해요(이동 속도, 벽, 손이 닿는 거리, 1초에 10번이 넘는 행동, 너무 고른 간격). 규칙에 안 맞는 것은 거절되고, 이상한 것은 계정에 기록돼요. 이 문서는 정상적인 클라이언트를 만들기 위한 것이에요.

연결

   
전송 QUIC (UDP), 기본 포트 7777
TLS 1.3만, ALPN greenhaven
인증서 서버가 처음 켜질 때 데이터 폴더에 만든 자체 서명 인증서. 처음 접속할 때 SHA-256을 기억해 두고(핀) 다음부터 다르면 끊으세요. 서버가 켜질 때 콘솔에 찍혀요.
메시지 클라이언트가 여는 양방향 스트림 하나. 프레임은 [길이 u32 리틀 엔디언][JSON], 한 메시지 최대 128 KB
데이터그램 QUIC 데이터그램. 위치와 음성처럼 최신 것만 쓸모 있는 것. 첫 바이트가 종류, 숫자는 모두 리틀 엔디언

연결 후 10초 안에 스트림을 열고 첫 메시지를 보내야 해요. 첫 메시지는 info, manage, join 중 하나예요.

서버 정보 (info)

접속하지 않고 서버 목록에 보일 것만 물어요. 답을 보내고 서버가 연결을 닫아요.

{"t": "info"}
{
  "t": "info",
  "name": "",
  "motd": "",
  "players": 3,
  "max": 20,
  "version": "<규칙 버전>",
  "protocol": 2,
  "game": "0.1.0",
  "password": false,
  "plugins": [],
  "host": false,
  "files": [{"kind": "pack", "size": 1048576}],
  "tags": [],
  "region": "",
  "scopes": []
}
  • game: 서버가 받는 게임 버전, protocol: 메시지 버전
  • files: 접속하면 받게 될 파일의 종류와 크기만(이름과 해시는 접속 뒤에)
  • host: 플레이어 게임의 자체 서버(접속하면 그 농장의 일꾼)
  • scopes: 서버의 플러그인이 쓰는 바깥 기능

접속 (join)

{
  "t": "join",
  "name": "닉네임",
  "secret": "<계정 비밀>",
  "game": "0.1.0",
  "proto": 2,
  "version": "<규칙 버전>",
  "ticket": "<중앙 서버 티켓>",
  "lang": "ko",
  "tz": 540,
  "reg": "<가진 레지스트리 해시>"
}
필드 뜻
name, secret 계정. 처음 보는 이름이면 그 비밀로 계정이 만들어지고, 다음부터는 비밀이 맞아야 해요. 이름은 1-32자, @로 시작 못 해요 (온라인 모드에서는 중앙 서버의 계정 id)
game, proto 게임 버전과 프로토콜 버전. 게임 버전이 서버와 다르면 proto가 같을 때만 들어가요. game을 안 보내면 version(규칙 버전)이 같아야 해요
ticket 온라인 모드 서버(server.properties online-mode가 false가 아닌 곳)는 공식 중앙 서버가 이 서버의 인증서 핀에 맞춰 발급한 티켓을 확인해요
password 서버 비밀번호(SHA-256 16진수, 또는 그대로)
fresh 새 농장으로 시작 (농장 자리는 들어간 뒤 site로 골라요: 행동)
seed 게임 자체 서버에서만: 새 월드의 씨앗 (새 월드 만들기에서 친 것)
lang, tz 언어, 시간대(UTC에서 동쪽으로 분)
reg 가지고 있는 레지스트리의 해시. 서버 것과 다를 때만 registry가 와요
peek 로비용: 친구 소식만 받고 누구에게도 접속 중으로 안 보임 ({"t":"peeked","name","code"} 다음 친구 목록)

플러그인이 접속 전에 물을 것이 있으면 {"t":"prompt","gui":{...}}가 오고, {"t":"prompt_answer","gui","button","inputs":{}}로 답해요.

성공:

{
  "t": "joined",
  "r": 0,
  "packs": [],
  "reg_hash": "...",
  "registry": {},
  "scopes": [],
  "now": 1761955200000
}
  • r: 서버가 마지막으로 받은 행동 번호. 월드와 게임 전체는 joined에 없고 바로 뒤의 enter로 와요
  • packs: (있을 때만) 받을 파일 [{"name","size","sha","kind":"pack"|"plugin","title","ver"}]
  • registry: 서버의 게임 콘텐츠(규칙 JSON, 플러그인 패치 포함). 가진 것(reg)과 reg_hash가 다를 때만 와요. reg_hash로 저장해 두세요
  • scopes: 서버의 플러그인이 쓰는 바깥 기능
  • now: 서버 시계(유닉스 ms). 행동과 위치의 시각은 이 시계로 찍어요

이어서 friends(친구 목록)와 {"t":"enter","world","look","station","terrain","state"}(들어간 월드와 게임 전체: 아래 상태; look: 보기만 할 수 있음, station: 걷기가 열기구 정거장에서 시작, terrain: 땅의 씨앗)가 와요. enter는 그 월드의 아침(그때는 "summary":[[id,n,값]...], "total": 밤사이 정산), 월드가 통째로 바뀔 때, 플러그인이 다른 월드로 옮길 때(Player::move_world)도 와요.

서버의 월드는 모두가 함께 쓰는 하나(world)뿐이고, 플러그인이 만든 월드만 더 있어요.

거절: {"t":"rejected","reason":"..."}. 덧붙는 것: "update":true,"game":"<서버 버전>"(게임을 업데이트해야 함), "password":true(비밀번호 틀림). 다른 곳에서 같은 계정으로 접속하면 먼저 연결에 rejected가 가요. 서버가 내보내면 {"t":"kicked","reason"}.

파일 받기

{"t": "pack", "name": "<이름>", "off": 0}
{"t": "pack", "name": "<이름>", "off": 0, "size": 123456, "d": "<base64, 최대 48 KB>"}

off를 늘려가며 받고 sha(SHA-256)로 확인하세요. 리소스 팩은 미디어(PNG, JPG, WebP, OGG, WAV, GLB)만 들어 있는 zip이에요. 실행되는 것은 없어요.

행동 (act)

게임 안에서 한 일은 일어날 때마다 하나씩 보내요. r은 1씩 늘어나요. 이미 받은 번호(마지막 이하)를 다시 보내면 ok가 다시 오고 두 번 적용되지 않아요.

{
  "t": "act",
  "r": 12,
  "e": {
    "k": "till",
    "t": 1761955200000,
    "d": 3,
    "m": 420,
    "x": 10.5,
    "z": 4.5,
    "c": [10, 4],
    "tool": "hoe"
  }
}
e 필드 뜻
k 종류 (아래)
t 시각(서버 시계 유닉스 ms)
d, m 게임 날짜, 하루의 분(6:00 = 360)
x, z 플레이어 위치(셀)
c 대상 셀 [x, z]
id, n, a 물건 id, 개수, 동물·주민·장비 칸·마법 부여
tool, why 쓴 도구, 이유(행동마다 다름)
pw cast: 던지는 힘 0-100
ch till / water: 모아 쓰기 단계
fx, fz strike: 바라본 방향
inp, ticks, hk fish: 릴 게임에서 버튼이 바뀐 틱, 플레이한 틱 수, 입질부터 챔질까지 ms
vals plugin: 플러그인 패널 입력값

종류: till water plant fertilize harvest hit strike cast fish forage item pickup discard eat use buy sell ship craft place unplace store take furnish unfurnish equip unequip upgrade smith_pick enchant perk talk gift answer words request daily quest giveup mail read_mail animal pet feed product collect release sapling fruit door enter mine gate build enlarge donate bundle love festival keg preserves_jar bee_house crab_pot skin_buy skin_use tutorial plugin gui button command sleep day. 서버가 플러그인과 규칙에 따라 판정하니 값은 레지스트리를 보세요.

답:

  • {"t":"ok","r":12,"res":{...},"s":{...}}: 받아들임. res는 클라이언트가 알 수 없는 결과(부서진 것의 drops, 채집 n, 낚시 cast의 level bite reel, fish의 id("": 놓침), strike의 hits [[몬스터, 남은 hp]], 땅에 떨어진 것 g). s는 바뀐 플레이어 상태의 키만
  • {"t":"no","r","why","state"}: 거절, 그 행동은 일어나지 않았어요. state(서버가 가진 상태)를 그대로 받아들이세요. 행동이 너무 빨라 거절될 때는 state가 없어요
  • 하루는 플레이어 한 사람이 끝내지 않아요: 월드의 모두가 잠들거나 하루의 끝 시각이면 월드의 하루가 끝나고, 아침은 enter(summary total 포함)로 와요
  • 집터 고르기: {"t":"site","at":[x,y,z]} → {"t":"site","state"}

상태

state는 한 플레이어의 게임 전체예요: day m weather weather_next gold energy max_energy health max_health bag layout tiles trees debris forage animals shipped xp levels perks gear placed chests equip quests friendship mail 등, 그리고 town(서버가 정한 마을: 셀, 부지, 구조물, 장소). 모르는 키는 무시하세요. 키는 플러그인과 버전에 따라 늘어나요.

서버가 보내는 것

메시지 언제
{"t":"clock","d","m"} 2초마다 월드 시계. 클라이언트 시계를 맞추세요
{"t":"world","d","m"} 같은 월드의 누군가가 바꾼 것
{"t":"blocks","l":[[x,y,z,블록]]} 블록이 바뀜
{"t":"back","why","x","z","y"} 이동이 거절됨: 그 자리로 돌아가세요 (y: 마지막으로 통과한 자리의 높이, 날던 중이면 그 높이로)
{"t":"hurt","s","n","health"} 몬스터에게 맞음
{"t":"fell","state","hold"} 쓰러짐(진료소에서 일어남). hold면 {"t":"respawn","state"}까지 기다림
{"t":"says","l":[[주민, 말, 지연]]} 주민의 말
{"t":"fx","list":[[종류,이름,x,y,z]]} 다른 사람 행동의 효과음과 파티클
{"t":"ents","e"} 플러그인 엔티티
{"t":"pdata","d"} 플러그인의 플레이어 값(바)
{"t":"gui","g"} 플러그인 패널
{"t":"ext","k","v"} 플러그인 API 메시지 (예: world.time)
{"t":"beds","all","s","ids"} 월드에 누가 있는지(그래서 누가 들어오고 나갔는지)와 그중 누가 자고 있는지, ids는 이름 → 데이터그램 6이 쓰는 번호
{"t":"chat","name","text"} 채팅 (보낸 사람에게도)

그 밖의 클라이언트 메시지

보냄 답
{"t":"chunk","k":[x,z]} {"t":"chunk","k","c"} (청크)
{"t":"chat","text"} 1-200자, 5초에 5줄까지. 넘으면 버려져요
{"t":"layout","layout"} 가방 순서와 선택 칸 (최대 64 KB)
{"t":"look","look"} 모습(species, skin, hat, glasses, back, body). 온라인 모드에서는 중앙 서버의 것이 쓰여요
{"t":"looks","names":[]} {"t":"looks","looks":{이름:모습},"held":{이름:모델}} (한 번에 32명)
{"t":"held","m","slot","id"} 손에 든 것의 모델 이름("": 없음), 단축바에서 고른 칸, 그 칸의 아이템. 다른 사람에게 {"t":"held","name","m"}
{"t":"mode","m"} 게임 쪽 코드가 정한 이동 모드 ("" 게임 그대로; 플러그인의 PlayerMove mode)
{"t":"line","at":[x,y,z]} 낚시찌 위치 (null: 감음). 다른 사람에게 {"t":"line","name","at"}
{"t":"status","name"} {"t":"status","name","online"} (친구만 true)
{"t":"pause","on"} 게임 자체 서버에서 혼자일 때만: 월드 시계 멈춤
{"t":"plugin_settings"} / {"t":"plugin_setting","plugin","key","value"} 플러그인 설정 목록 / 바꾸기 (게임 자체 서버의 주인만)

친구

중앙 서버를 쓰는 서버에서는 친구 목록이 중앙 서버의 것이에요. 아래는 online-mode=false 서버의 자체 친구예요.

friend (name, yes?), friend_find (q), friend_cancel, unfriend, block (undo), friend_note (memo, fav), friend_policy (all / played / none), friends. 서버는 friends (전체 목록), friend_status, friend_ask, friend_found, friend(why: 거절)를 보내요.

데이터그램

클라이언트 → 서버

종류 형식  
1 [1][walk u16][x f64][y f32][z f64][yaw f32][doing u8][t i64 ms][flags u8] (37바이트) 플레이어 위치. 주변에 누가 있으면 초당 30번, 아니면 5번, 서 있으면 1초에 한 번
2 [2][Opus 프레임] (최대 1024바이트) 음성: 같은 월드 48 m 안의 사람에게만 전해져요(게임은 40 m까지 들려줘요). 1초에 60개까지
11 [11][플러그인 이름 길이 u8][플러그인 이름][채널 길이 u8][채널][데이터] (최대 1200바이트) 플러그인 게임 쪽 코드의 빠른 패킷: 그 플러그인의 FastPacket에만. 1초에 60개까지

위치 하나로 세 가지를 해요. 같은 월드의 다른 사람에게 전해지고, 서버가 걷기를 검사하고(벽, 속도, 물, 공중: 안 되면 back), 서버의 몬스터가 검사를 통과한 자리를 쫓아요. 걷기 검사는 t 기준 0.19초에 한 번까지만 해요. 좌표는 지금 있는 월드의 셀(1 m). walk는 새 걷기(부활, 순간이동)마다 늘리는 번호. t는 클라이언트 시각 (유닉스 ms). flags는 1 = 지금은 걷기 검사 안 함(문 응답 대기, 도구 동작, 열기구, 쓰러짐), 2 = 쓰러짐(몬스터가 안 쫓음). doing은 하위 7비트가 0 서 있음, 1 걷기, 2 달리기, 3 잠, 4 도구, 5 수영, 6 점프, 7 낙하, 8 입질, 9 릴 감기, 최상위 비트는 도구 동작을 시작할 때마다 뒤집어요. 프로토콜 2부터 (그 전엔 1, 3, 4번이 따로였어요).

서버 → 클라이언트

종류 형식
6 [6][개수 u8] 다음 각 [id u16][ms u32][x i32][y f32][z i32][yaw u8][doing u8] (20바이트): 같은 월드의 다른 사람들 (16 ms마다 묶어서, 최대 1200바이트). id는 그 월드 안 번호(beds의 ids가 이름을 알려 줘요), ms는 보낸 쪽 시각의 하위 32비트(순서용), x/z는 칸의 1/256 단위, yaw는 256분의 1바퀴. 받는 사람마다 거리에 따라: 10 m 안은 올 때마다(그 사람에게 가장 가까운 10명만; 더 붐비면 한 단계 아래), 25 m 안은 초당 15번, 50 m 안은 10번, 150 m 안은 5번, 그 밖은 1초에 한 번(지도·근처 목록용). 그 사람에게 마지막으로 보낸 것과 위치·높이·방향·동작이 같으면(서 있으면) 거리와 상관없이 1초에 한 번
2 [2][이름 길이 u8][이름][Opus]: 48 m 안에서 말한 음성 (바로)
3 [3][개수 u8][층 u32] 다음 각 [serial u16][kind u8][tier u8][hp u16][x f32][z f32]: 몬스터 (층 0 = 지상; 한 데이터그램에 81마리까지)
5 [5][개수 u8][all u8] 다음 각 [n u8][x f32][z f32][yaw u8][place u8][work u8][flags u8][in_for u8][act u8][acts u8][with u8]: 주민. all 1이면 전체 목록(1초마다; 여러 개로 나뉘면 첫 것만 1), 0이면 그 뒤로 바뀐 것만. 바뀐 게 없으면 안 와요. 광산·던전에 있는 사람에게는 전체 목록만 가요
7 [7][개수 u8][all u8] 다음 각 [id u32][x f32][z f32][flags u8]: 농장 동물, 5와 같은 방식(all 1이면 목록을 비우고 다시 채우세요)
12 [12][플러그인 이름 길이 u8][플러그인 이름][채널 길이 u8][채널][데이터]: 플러그인 서버 쪽의 빠른 패킷 (Hud::send_fast)
  • 데이터그램은 잃어버릴 수 있어요. 보이는 사람은 1초마다 다시 오니 3초 동안 소식 없는 사람은 지우세요
  • 3(몬스터)은 목록이 비면 한 번, 그 뒤로는 1초에 한 번만 와요(비어 있지 않으면 초당 10번)
  • 5의 yaw는 256분의 1바퀴, flags는 1 움직임, 2 달림, 4 실내, 8 숨김, place/in_for는 레지스트리 장소 번호
  • 7의 id는 동물 id의 FNV-1a 32비트, flags는 1 걷기, 2 누움(밤), 4 풀 뜯기, 8 먹기

청크

월드는 32 x 32 칸, 높이 64칸(y -8부터) 블록 청크예요. 서버는 {"t":"chunk","k","z","n"}로 답해요: z는 아래 바이트를 zlib(deflate)로 눌러 base64로 적은 것, n은 누르기 전 크기(평균 10 KB가 650 B쯤으로 줄어요). 요청은 플레이어가 있는 청크(마지막으로 보낸 위치)에서 8청크 안만, 초당 60개까지 받아요(그 밖은 읽지 않고 버려요: 게임이 3초 뒤 다시 물어요). 누른 바이트는:

  1. 1024개 열(x가 빠르게, 그다음 z). 각 열은 [런 개수 u8] 다음 [블록 u8, 개수 u8] 쌍, y -8부터 위로. 마지막 런 위는 공기
  2. 나머지는 MessagePack으로 된 소품 배열: {"m": 모델 res:// 경로, "x","y","z","size","yaw", "solid": [반지름, 높이] 또는 null}

블록: 0 air, 1 grass, 2 sand, 3 snow, 4 stone, 5 dirt, 6 road, 7 bed(물 밑 모래), 8 water. 그 위의 번호는 플러그인 블록이에요. 블록 y는 y-0.5부터 y+0.5까지 차지해요.

관리 (manage)

24시간 서버의 주인이 접속 없이 설정을 묻거나 바꿔요. 중앙 서버 티켓으로 주인을 확인해요.

{
  "t": "manage",
  "name": "닉네임",
  "ticket": "<중앙 서버 티켓>",
  "props": {"motd": "어서 오세요"}
}
{
  "t": "manage",
  "owner": true,
  "props": {},
  "running": {},
  "restart": false,
  "players": 2,
  "saved": null,
  "online": ["닉네임"],
  "started": 1761955200000,
  "log": [[1761955200000, "닉네임 들어옴"]],
  "bans": [],
  "plugins": [],
  "plan_plugins": []
}
  • owner: 주인이 아니면 false만 와요
  • props: 저장된 설정(다음 시작에 쓰임), running: 지금 쓰이는 설정, restart: 둘이 달라 다시 시작해야 함
  • props 대신 op로 kick, ban, unban, 플러그인 켜기/끄기 같은 일을 시켜요