바닐라 "LAN 에 공개" 의 상수 8 을 믹스인 1개로 30 으로 바꾼다. 대상과 메서드를 문자열로만 적고 핸들러를 int -> int 로 만들어 마인크래프트 타입 참조 0 을 유지한다. 8 이 있는 자리 (바닐라 jar 바이트코드 실측, 버전마다 정확히 한 곳): - 1.17 ~ 1.21.8 class_1130.<init> (super 인자 → PlayerList.maxPlayers) - 1.21.9 ~ 1.21.11 class_1132.method_3802 (return 8) - 26.1 ~ 26.3 IntegratedServer.getMaxPlayers (공식 이름) @Pseudo + require=0 이라 자리를 못 찾는 버전은 크래시 없이 바닐라 8 로 돈다. 적용 여부는 웹 콘솔 "최대 인원" 과 로그로 보인다. tools/lan-limit-probe: 바닐라 jar + Fabric Loader 로 모드를 실제 로드해 확인하는 도구. 1.18.2 ~ 26.3 (11개 버전, 로더 0.19.5 / 0.14.25) 전부 30, 믹스인을 뺀 jar 는 8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 KiB
mc_p2p_mod
초대코드 하나로 싱글플레이 월드를 친구와 함께 플레이하는 마인크래프트 P2P 모드.
jar 파일 하나가 모든 마인크래프트 버전에서 동작한다. 1.21 용, 1.21.8 용, 26.3 용을 따로 내려받지 않는다. 버전별 빌드도, 매핑도 없다. 어떻게 가능한지는 docs/DESIGN.md 에 근거와 함께 적어 두었다.
| 전송 | QUIC (TLS 1.3 종단간 암호화) |
| 네이티브 라이브러리 | 0개 |
| jar 크기 | 약 0.71 MB |
| 지원 버전 | Fabric 이 도는 모든 버전 (JVM 17 이상) |
| 최대 인원 | 30명 (확장할 수 없는 버전은 바닐라 8명) |
| 믹스인 | 1개 — LAN 최대 인원 전용, 안 맞으면 스스로 빠진다 |
| 마인크래프트 클래스 참조 | 0개 (테스트로 강제) |
설치
- Fabric Loader 0.14 이상을 설치한다.
mc_p2p_mod-<버전>.jar을mods폴더에 넣는다.- 게임을 켠다. 로그에 조작 주소가 찍힌다.
[mc_p2p] 준비 완료 — 조작은 http://127.0.0.1:25585 에서 한다
Fabric API 는 필요하지 않다. 게임 코드에서 건드리는 곳은 "LAN 에 공개" 의 최대 인원 상수 하나뿐이다(아래 "최대 인원").
처음 한 번: 시그널링 서버 준비
P2P 는 두 사람이 서로의 주소를 알아야 시작된다. 그 주소를 교환해 주는 작은 서버가 필요하다. 남의 서버에 의존하지 않도록, 이 저장소에 서버가 같이 들어 있다.
아무 VPS 한 대에서:
java -jar mc_p2p_mod-<버전>-rendezvous.jar 25580 40000-40100
25580— 시그널링용 TCP 포트40000-40100— 중계용 UDP 포트 범위. 방화벽에서 이 범위만 열면 된다. 생략하면 임의 포트를 쓰므로 방화벽 설정이 번거로워진다.
메모리는 수십 MB 면 충분하다. 홀펀칭이 성공하는 한 트래픽은 이 서버를 지나지 않는다.
서버를 띄웠으면 게임에서 http://127.0.0.1:25585 를 열고 주소를 넣고 저장한다.
예: p2p.example.com:25580
외부 서버를 쓰지 않는다
이 모드에는 다른 사람의 시그널링·중계 서버 주소가 기본값으로 들어 있지 않다. rendezvous
는 비어 있는 상태로 시작하고, 사용자가 직접 띄운 서버를 넣어야 비로소 동작한다. 남의 인프라에
얹혀 가는 구성을 아예 만들 수 없게 한 것이다.
남는 외부 호출은 stun 기본값 하나뿐이다(stun.l.google.com:19302). 공인 주소를 알아내는 데만
쓰이고 게임 트래픽은 지나가지 않는다. 그래도 완전히 없애고 싶으면 두 가지 방법이 있다.
- 같은 공유기 안에서만 쓸 거라면
stun을 빈 값으로 둔다. 외부 호출이 0 이 된다. - 인터넷을 넘어야 하면 직접 띄운 STUN 서버(coturn 등)를 적는다.
방 만들기 (호스트)
- 웹 콘솔에서 호스트 준비 를 누른다.
- 게임에서 월드에 들어가 일시정지 → LAN 에 공개 를 누른다.
- 웹 콘솔에 초대코드가 나타난다. 친구에게 준다.
K7Q2M9-F4M1TX-9VB0RS-T3WXZ8
바닐라의 "LAN 에 공개" 를 그대로 쓴다. 모드가 그 순간을 감지해서 방을 연다. 월드를 닫으면 방도 알아서 정리된다.
방 들어가기 (참가자)
- 웹 콘솔에 초대코드를 붙여넣고 참가 를 누른다.
- 게임의 멀티플레이 화면을 연다.
- LAN 목록에 방이 나타난다. 누르면 접속된다.
주소를 입력하거나 서버를 추가할 필요가 없다. 목록에 직결/중계와 지연 시간이 같이 표시된다.
P2P 방 · 직결 23ms
최대 인원
바닐라 "LAN 에 공개" 는 8명으로 막혀 있다. 이 모드는 그걸 30명으로 올린다. 호스트 쪽 모드만 일하면 되고 설정할 것은 없다.
버전마다 8 이 박힌 자리가 달라서, jar 하나에 알려진 자리를 전부 넣어 두고 실행 중인 버전에 있는 자리만 바꾼다.
| 버전 | 8 이 있는 곳 | 결과 |
|---|---|---|
| 1.17 ~ 1.21.8 | IntegratedPlayerList 생성자 → PlayerList.maxPlayers |
30명 |
| 1.21.9 ~ 1.21.11 | IntegratedServer.getMaxPlayers() |
30명 |
| 26.1 ~ 26.3 | IntegratedServer.getMaxPlayers() (공식 이름) |
30명 |
| 그 밖의 미래 버전 | 자리가 바뀌었을 수 있음 | 못 찾으면 8명 으로 그대로 동작 |
자리를 못 찾아도 게임은 크래시하지 않는다. 확장만 빠지고 바닐라 8명으로 돈다. 실제로 적용됐는지는 웹 콘솔의 최대 인원 칸과 로그로 확인한다.
[mc_p2p] LAN 최대 인원을 8 → 30 명으로 올렸다
게임 로그 맨 앞에 아래 같은 WARN 이 한두 줄 찍히는 것은 정상이다. 다른 버전용 대상 이름을
찾다가 없다고 알리는 것이다(1.21.x 에서는 IntegratedServer, 26.x 에서는 class_1130/class_1132).
[main/WARN]: Error loading class: net/minecraft/client/server/IntegratedServer (java.lang.ClassNotFoundException: ...)
연결 방식
경로를 동시에 시도해서 되는 것을 쓴다.
- 직결 (host) — 같은 공유기 안이면 가장 빠르다.
- 직결 (srflx) — STUN 으로 공인 주소를 알아내 양쪽이 동시에 패킷을 보내 NAT 에 구멍을 뚫는다. 가정용 공유기 대부분에서 성공한다.
- 중계 (relay) — 대칭 NAT·CGNAT 처럼 구멍이 안 뚫리는 환경의 보험. 시그널링 서버가 UDP 데이터그램을 그대로 넘긴다.
중계는 거의 항상 즉시 성공하므로, 아무 장치 없이 "먼저 된 것" 을 쓰면 직결이 가능한데도 중계로 굳어버린다. 그래서 직결에 기본 600ms 의 유예를 준다. 그 안에 홀펀칭이 되면 직결을 쓰고, 안 되면 중계로 넘어간다.
중계를 지나더라도 QUIC 의 TLS 1.3 이 종단간이라 중계 서버는 게임 트래픽을 읽을 수 없다.
설정
config/mc_p2p.properties. 웹 콘솔에서 바꾸는 값도 여기 저장된다.
| 키 | 기본값 | 설명 |
|---|---|---|
rendezvous |
(비어 있음) | 시그널링 서버 호스트:포트 |
stun |
stun.l.google.com:19302 |
비우면 같은 LAN 전용이 된다 |
webPort |
25585 |
웹 콘솔 포트. 루프백에만 열린다 |
relayOnly |
false |
직결을 건너뛰고 중계만 쓴다(디버깅용) |
directPreferenceMs |
600 |
직결에 주는 유예 시간 |
pathTimeoutMs |
8000 |
경로 탐색 포기 시각 |
debug |
false |
상세 로그 |
보안
- 방 비밀 — 초대코드는 120비트 난수다. 호스트는 QUIC 스트림 첫머리에서 이 값을 대조한 뒤에야 통합 서버로 연결을 넘긴다. 코드가 없으면 방 ID 를 알아도 들어올 수 없다.
- 시그널링 서버는 초대코드를 모른다 — 서버에 등록되는 방 ID 는 코드의 SHA-256 에서 파생된 값이다. 서버 운영자도 방에 몰래 들어오거나 호스트를 사칭할 수 없다.
- 중간자 차단 — 호스트는 매번 새 인증서를 만들고, 참가자는 시그널링으로 받은 SHA-256 지문과 실제 핸드셰이크의 인증서를 대조한다. 다르면 즉시 끊는다.
- 웹 콘솔은 127.0.0.1 에만 바인딩된다 — 방을 열고 닫을 수 있는 창구이므로 LAN 에도 노출하지 않는다.
- 참가자 리스너도 127.0.0.1 전용이다 — 같은 공유기의 다른 기기가 초대코드 없이 올라타지 못한다.
시그널링 연결 자체는 아직 평문 TCP 다. 중간자가 이 경로를 장악하면 지문을 바꿔칠 수 있다. TLS 적용은 남은 과제로 docs/DESIGN.md 에 적어 두었다.
빌드
./gradlew build
Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고 몇 초 안에 끝난다.
산출물은 build/libs/ 에 두 개가 나온다.
mc_p2p_mod-<버전>.jar— 모드mc_p2p_mod-<버전>-rendezvous.jar— 시그널링/중계 서버
테스트만 돌리려면 ./gradlew test.
검증 상태
솔직하게 적는다.
기계로 확인된 것 (./gradlew test, 59개):
- 터널 전 구간 — 펀칭 프로브, 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조, TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다.
- 512KB 연속 전송이 손실 없이 도착한다(청크 전송 상황).
- 틀린 초대코드는 호스트가 거절하고 통합 서버에 닿지 않는다.
- 지문이 다르면 참가자가 연결을 끊는다.
- 직결과 중계가 동시에 유효할 때 직결이 이긴다.
- LAN 비콘을 실제 멀티캐스트로 쏘고 받아 포트를 읽는다.
- 시그널링 프로토콜 — 등록, 참가, 방 중복, 호스트 이탈 시 방 정리, 잘못된 입력.
- 웹 콘솔 — 실제 HTTP 요청/응답, 루프백 전용 바인딩.
- 마인크래프트 클래스 참조 0개 — 컴파일된 클래스의 상수 풀을 뒤져서 강제한다. 믹스인도 대상을 문자열로만 적고 게임 타입 서술자를 갖지 않는다는 것까지 검사한다.
실제 Fabric 로더 위에서 확인된 것 (tools/lan-limit-probe):
- 바닐라 클라이언트 jar + Fabric Loader 로 모드를 실제로 로드해 최대 인원 믹스인이 적용되는지
본다. 1.18.2 · 1.20.1 · 1.21.1 · 1.21.8 · 1.21.9 · 1.21.11 · 26.1 · 26.3 전부 30.
1.21.9 이상은
getMaxPlayers()를 직접 호출해 30 을 받았고, 1.21.8 이하는 생성된PlayerList의maxPlayers필드가 30 이다. 로더 0.19.5, 그리고 하한인 0.14.25(1.18.2·1.20.1).
아직 확인되지 않은 것:
- 실제 마인크래프트 클라이언트로 접속해 본 검증. 루프백 테스트는 프로토콜 계층까지만 본다.
- 실제 NAT 를 넘는 홀펀칭 성공률. 테스트는 같은 기기 안이라 NAT 가 없다.
- 화면을 띄운 실제 게임에서의 전체 흐름. 위 검증은 게임 메인 직전(preLaunch)에서 멈춘다. 최대 인원도 "판정에 쓰이는 값이 30" 까지 봤고, 실제로 9번째 이후 플레이어를 접속시켜 보지는 않았다.
라이선스
MIT. LICENSE 참고.