Compare commits

...

2 Commits

Author SHA1 Message Date
EJClaw
16a44ec4bc feat: LAN 최대 인원 8 → 30 (단일 jar, 확장 불가 버전은 8 유지)
Some checks failed
build / build (push) Has been cancelled
바닐라 "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>
2026-10-05 04:07:06 +09:00
EJClaw
40b8894a98 docs: 참고 대상의 제작자·저장소·서버 주소를 문서에서 제거
Some checks failed
build / build (push) Has been cancelled
코드와 산출물 jar 에는 애초에 외부 서버 주소가 없었으나(rendezvous 기본값이
빈 문자열), 설계 문서에 참고 대상의 제작자명과 저장소 경로가 남아 있었다.
기술적 관찰 사실은 설계 판단의 근거로 필요하므로 유지하되 "기존 구현" 으로
중립화했다.

더불어 이 프로젝트가 남의 인프라에 얹혀 가지 않는다는 점을 README 에 명시했다.
rendezvous 는 빈 값으로 시작해 사용자가 직접 띄운 서버만 쓰며, 유일하게 남는
외부 호출인 STUN 기본값도 제거하는 방법을 함께 적었다.

검증: 작업 트리와 양쪽 산출물 jar 전수 검색 0건, 테스트 57개 전부 통과.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-28 11:33:14 +09:00
13 changed files with 503 additions and 19 deletions

View File

@@ -3,7 +3,7 @@
초대코드 하나로 싱글플레이 월드를 친구와 함께 플레이하는 마인크래프트 P2P 모드.
**jar 파일 하나가 모든 마인크래프트 버전에서 동작한다.** 1.21 용, 1.21.8 용, 26.3 용을 따로
내려받지 않는다. 버전별 빌드도, 매핑도, 믹스인도 없다. 어떻게 가능한지는
내려받지 않는다. 버전별 빌드도, 매핑도 없다. 어떻게 가능한지는
[docs/DESIGN.md](docs/DESIGN.md) 에 근거와 함께 적어 두었다.
| | |
@@ -12,7 +12,8 @@
| 네이티브 라이브러리 | 0개 |
| jar 크기 | 약 0.71 MB |
| 지원 버전 | Fabric 이 도는 모든 버전 (JVM 17 이상) |
| 믹스인 | 없음 |
| 최대 인원 | 30명 (확장할 수 없는 버전은 바닐라 8명) |
| 믹스인 | 1개 — LAN 최대 인원 전용, 안 맞으면 스스로 빠진다 |
| 마인크래프트 클래스 참조 | 0개 (테스트로 강제) |
---
@@ -27,8 +28,8 @@
[mc_p2p] 준비 완료 — 조작은 http://127.0.0.1:25585 에서 한다
```
Fabric API 는 필요하지 않다. 다른 모드와 충돌할 여지도 사실상 없다 — 이 모드는 게임 코드를
한 줄도 건드리지 않는다.
Fabric API 는 필요하지 않다. 게임 코드에서 건드리는 곳은 "LAN 에 공개" 의 최대 인원 상수
하나뿐이다(아래 "최대 인원").
---
@@ -52,6 +53,18 @@ java -jar mc_p2p_mod-<버전>-rendezvous.jar 25580 40000-40100
서버를 띄웠으면 게임에서 `http://127.0.0.1:25585` 를 열고 주소를 넣고 저장한다.
예: `p2p.example.com:25580`
### 외부 서버를 쓰지 않는다
이 모드에는 **다른 사람의 시그널링·중계 서버 주소가 기본값으로 들어 있지 않다.** `rendezvous`
는 비어 있는 상태로 시작하고, 사용자가 직접 띄운 서버를 넣어야 비로소 동작한다. 남의 인프라에
얹혀 가는 구성을 아예 만들 수 없게 한 것이다.
남는 외부 호출은 `stun` 기본값 하나뿐이다(`stun.l.google.com:19302`). 공인 주소를 알아내는 데만
쓰이고 게임 트래픽은 지나가지 않는다. 그래도 완전히 없애고 싶으면 두 가지 방법이 있다.
- 같은 공유기 안에서만 쓸 거라면 `stun` 을 빈 값으로 둔다. 외부 호출이 0 이 된다.
- 인터넷을 넘어야 하면 직접 띄운 STUN 서버(coturn 등)를 적는다.
---
## 방 만들기 (호스트)
@@ -81,6 +94,37 @@ 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: ...)
```
---
## 연결 방식
경로를 동시에 시도해서 되는 것을 쓴다.
@@ -153,7 +197,7 @@ Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고
솔직하게 적는다.
**기계로 확인된 것** (`./gradlew test`, 57개):
**기계로 확인된 것** (`./gradlew test`, 59개):
- 터널 전 구간 — 펀칭 프로브, 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조,
TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다.
@@ -164,13 +208,22 @@ Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고
- LAN 비콘을 실제 멀티캐스트로 쏘고 받아 포트를 읽는다.
- 시그널링 프로토콜 — 등록, 참가, 방 중복, 호스트 이탈 시 방 정리, 잘못된 입력.
- 웹 콘솔 — 실제 HTTP 요청/응답, 루프백 전용 바인딩.
- **마인크래프트 클래스 참조 0개** — 컴파일된 클래스의 상수 풀을 뒤져서 강제한다.
- **마인크래프트 클래스 참조 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 가 없다.
- 1.21.x 와 26.x 양쪽에서의 실제 로드 확인.
- 화면을 띄운 실제 게임에서의 전체 흐름. 위 검증은 게임 메인 직전(preLaunch)에서 멈춘다.
최대 인원도 "판정에 쓰이는 값이 30" 까지 봤고, 실제로 9번째 이후 플레이어를 접속시켜 보지는 않았다.
---

View File

@@ -27,6 +27,10 @@ dependencies {
// 이 인터페이스는 로더 소유라 리매핑 대상이 아니며 모든 버전에서 동일하다.
compileOnly("net.fabricmc:fabric-loader:0.18.4")
// LAN 최대 인원 믹스인(mixin/LanMaxPlayersMixin) 의 어노테이션만 쓴다. Mixin 은 로더가
// 런타임에 들고 있으므로 번들하지 않는다. 대상은 문자열이라 매핑도 Loom 도 필요 없다.
compileOnly("net.fabricmc:sponge-mixin:0.17.4+mixin.0.8.7")
// 순수 자바 QUIC. 네이티브 라이브러리 0개 (docs/DESIGN.md 의 전송 계층 비교 참고).
implementation("tech.kwik:kwik:0.11")

View File

@@ -15,8 +15,12 @@
## 2. "jar 하나로 모든 버전" 이 왜 어려운가
기존 P2P 모드들(참고 대상: `KITE2459/kfcudp-instant-p2p`)은 Stonecutter 로 17개 버전을
각각 빌드한다. 게을러서가 아니라, 마인크래프트 클래스를 참조하면 그 방법밖에 없기 때문이다.
기존에 배포되는 P2P 모드들은 Stonecutter 로 17개 버전을 각각 빌드한다. 게을러서가 아니라,
마인크래프트 클래스를 참조하면 그 방법밖에 없기 때문이다.
> 이 문서에서 "기존 구현" 이라고 적은 것은 착수 전 조사 단계에서 살펴본 배포본 한 종을
> 가리킨다. 설계 판단의 근거를 남기기 위해 관찰한 사실만 적었고, 제작자·저장소·서버 주소는
> 적지 않는다. 코드를 가져오지 않았고 그들의 인프라에 접속하지도 않는다.
결정적인 단절이 2026년에 생겼다. **마인크래프트 26.1부터 코드가 비난독화되면서 Fabric 의
Yarn/intermediary 매핑이 폐지됐다.**
@@ -79,8 +83,8 @@ GUI 코드가 한 줄도 필요 없다.
### 얻은 것
- 버전별 빌드 17개 → 1개
- Stonecutter 조건부 주석 (참고 모드의 `RoomListScreen.java` 한 파일에만 96개) → 0개
- 믹스인 14개 → 0개. 다른 모드와 충돌할 지점도 함께 사라졌다.
- Stonecutter 조건부 주석 (기존 구현은 GUI 한 파일에만 96개) → 0개
- 믹스인 14개 → 1개(LAN 최대 인원 전용, 7절). 다른 모드와 충돌할 지점도 거의 사라졌다.
- Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸
- 게임 API 를 안 건드리므로 새 마인크래프트 버전이 나와도 할 일이 없다
@@ -105,7 +109,7 @@ GUI 코드가 한 줄도 필요 없다.
| NAT traversal | 직접 구현 | libwebrtc 내장 (검증 충분) |
**순수 자바 QUIC을 선택했다.** 크기가 42배 차이 나고, 아키텍처 제약이 사라지는 것이 결정적이다.
참고 모드의 WebRTC 빌드는 `linux-aarch64` 를 번들에서 빠뜨려서 ARM 리눅스에서 아예 실패하는데,
기존 구현의 WebRTC 빌드는 `linux-aarch64` 를 번들에서 빠뜨려서 ARM 리눅스에서 아예 실패하는데,
네이티브가 없으면 그런 실수를 할 여지 자체가 없다. "jar 하나로 모든 버전" 목표와도 맞는다.
QUIC 은 암호화·혼잡제어·다중화를 프로토콜 한 겹에서 주므로 DTLS+SCTP+ICE 조합보다 얇다.
@@ -144,7 +148,7 @@ siphash 뿐이고 모두 순수 자바다. 클래스 파일 major 55 = Java 11
때문이다. 최악의 대기가 600ms 인 대신 구현이 훨씬 단순하다.
`P2PSocket.awaitPath` 에 이 판단을 적어 두었고, 갈아타기는 아래 남은 과제로 넘겼다.
참고 모드는 이걸 다르게 풀었다. 1차는 TURN 후보를 아예 만들지 않고, 실패하면 2차에 릴레이를
기존 구현은 이걸 다르게 풀었다. 1차는 TURN 후보를 아예 만들지 않고, 실패하면 2차에 릴레이를
허용해 재협상한다. 목적은 같지만 세션을 두 번 만들고, 양쪽이 같은 단계로 맞춰야 해서
"조인자의 OFFER 재협상 횟수로 호스트가 단계를 유추" 하는 조율이 들어간다. 유예 방식은
세션 하나로 끝난다.
@@ -215,16 +219,65 @@ JDK 안에서 끝내면 그 변수가 사라진다.
---
## 7. 남은 과제
## 7. 최대 인원: 믹스인 1개를 들인 이유
"LAN 에 공개" 는 8명이 한계다. 클라이언트 코드에 상수로 박혀 있어 네트워크 쪽에서 우회할 수
없다 — 참가 판정(`PlayerList.canPlayerLogin`)이 `players.size() >= 최대값` 을 직접 본다.
30명 방을 만들려면 게임 코드의 그 8 을 바꾸는 수밖에 없고, Fabric 에서 그 수단은 믹스인뿐이다.
2절의 원칙을 깨지 않는 방법이 있었다. **대상과 메서드를 문자열로만 적고, 핸들러를 `int -> int`
로 만들면 믹스인 클래스에 마인크래프트 타입이 하나도 들어가지 않는다.** 그러면 이름 체계가 다른
여러 버전의 대상을 한 클래스에 나란히 적을 수 있다.
### 8 이 있는 자리 (실측)
바닐라 클라이언트 jar 를 버전별로 내려받아 `javap` 로 `bipush 8` 을 셌다. 두 클래스
(`IntegratedServer`, `IntegratedPlayerList`)를 합쳐 **모든 버전에서 정확히 한 곳**이었다.
| 버전 | 이름 체계 | 위치 |
|---|---|---|
| 1.17.1 ~ 1.21.8 | intermediary | `class_1130.<init>` — `super(..., 8)` 인자, `PlayerList.maxPlayers` 필드로 간다 |
| 1.21.9 ~ 1.21.11 | intermediary | `class_1132.method_3802` — `return 8` |
| 26.1 ~ 26.3 | 공식 이름 | `IntegratedServer.getMaxPlayers` — `return 8` |
1.21.9 에서 `PlayerList.maxPlayers` 필드가 사라지고 판정이 `server.getMaxPlayers()` 로 바뀌었다.
즉 "필드를 리플렉션으로 바꾸기" 는 1.21.8 까지만 통한다. 최신 버전까지 덮으려면 메서드 본문의
상수를 바꿔야 해서 리플렉션이 아니라 믹스인이어야 했다.
### 설계
`LanMaxPlayersMixin` 하나가 세 대상을 동시에 노린다.
- `@Pseudo` — 실행 중인 버전에 없는 대상 클래스는 건너뛴다.
- `@ModifyConstant(intValue = 8, require = 0)` — 메서드 선택자 `<init>`, `method_3802`,
`getMaxPlayers` 중 있는 것에서 8 을 찾는다. 없으면 실패하지 않는다.
- 설정 파일도 `required: false`, `defaultRequire: 0`.
- 1.21.8 이하에서 8 은 `super()` 호출 전이라 `this` 가 없다. 그래서 핸들러가 static 이다.
- 핸들러는 `MaxPlayers.replace` 를 부르고, 8 이 아닌 값은 그대로 돌려준다.
결과적으로 미래 버전에서 자리가 바뀌면 이 믹스인은 아무것도 안 하고 게임은 8명으로 돈다.
"올릴 수 있는 버전은 30, 아니면 8" 이 jar 하나에서 저절로 갈린다. 실제 적용 여부는
`MaxPlayers.raised()` 로 웹 콘솔에 표시한다.
치른 비용: 시작 로그에 "없는 대상 클래스" WARN 이 한두 줄 찍힌다(`@Pseudo` 의 정상 동작).
그리고 새 마인크래프트 버전이 나오면 이 표 한 줄은 다시 확인해야 한다. 2절에서 말한 "새 버전에
할 일이 없다" 는 이 기능에 한해서는 "확인할 일이 하나 있다" 로 바뀐다. 확인은
`tools/lan-limit-probe` 로 한다 — 바닐라 jar 와 Fabric Loader 를 받아 모드를 실제로 로드하고,
게임 메인 직전에 대상 클래스를 불러 값이 30 인지 본다.
---
## 8. 남은 과제
우선순위 순.
1. **실제 게임 검증.** 루프백 테스트는 프로토콜 계층까지만 본다. 1.21.x 와 26.x 양쪽에서
실제로 로드되고 접속되는지 확인해야 한다. 나머지 항목보다 이게 먼저다.
1. **실제 게임 검증.** 루프백 테스트는 프로토콜 계층까지만 본다. Fabric Loader 위에서 모드가
로드되고 최대 인원 믹스인이 적용되는 것까지는 1.18.2 ~ 26.3 에서 확인했다(7절). 화면을 띄운
실제 게임에서 접속되는지, 9명 이상이 실제로 들어오는지는 아직이다. 나머지 항목보다 이게 먼저다.
2. **실제 NAT 뒤에서의 홀펀칭 성공률 측정.** 같은 기기 안 테스트에는 NAT 가 없다. 대칭 NAT·
CGNAT 비율에 따라 중계 의존도가 결정되므로 숫자를 봐야 다음 판단을 할 수 있다.
3. **시그널링 TLS.** 지금은 평문 TCP 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수
있다. 참고 모드도 같은 약점을 갖고 있다(기본 `ws://`). TLS 를 씌우거나 초대코드에서
있다. 기존 구현도 같은 약점을 갖고 있다(기본 `ws://`). TLS 를 씌우거나 초대코드에서
파생한 키로 시그널링 메시지를 인증하면 된다. 후자가 서버 인증서 없이 되므로 더 맞을 수 있다.
4. **중계 → 직결 승격.** 4절에서 미룬 것. 중계로 먼저 붙여 대기를 0 으로 만들고, 홀펀칭이
성공하면 조용히 갈아탄다. QUIC 의 연결 마이그레이션(CID 유지)을 쓰면 재핸드셰이크 없이
@@ -254,4 +307,8 @@ JDK 안에서 끝내면 그 변수가 사라진다.
- [NAT Traversal — iroh](https://docs.iroh.computer/concepts/nat-traversal) — 중계 우선 + 직결 승격, 성공률
- [How Tailscale is improving NAT traversal](https://tailscale.com/blog/nat-traversal-improvements-pt-1) — DERP 폴백 모델
- [Kwik](https://github.com/ptrd/kwik) — 순수 자바 QUIC
- 참고 구현: `KITE2459/kfcudp-instant-p2p` (CC0) — 문제 정의와 UX 를 참고했다. 코드는 가져오지 않았다.
이 목록 외에, 착수 전 조사 단계에서 배포 중인 P2P 모드 한 종을 분석해 문제 정의와 UX 를
참고했다. 본문에서 "기존 구현" 이라고 쓴 것이 그것이다. 제작자·저장소·서버 주소는 의도적으로
적지 않는다. 코드를 가져오지 않았고, 그들의 시그널링·STUN·TURN 서버를 쓰지도 않는다 —
이 프로젝트는 사용자가 직접 띄운 서버만 사용한다(README 의 시그널링 서버 항목 참고).

View File

@@ -0,0 +1,38 @@
package kr.tkrmagid.mcp2p;
/**
* "LAN 에 공개" 의 8인 제한을 30인으로 올린다. 실제 치환은 {@code mixin.LanMaxPlayersMixin} 이
* 하고, 여기는 그 값과 "실제로 적용됐는가" 만 들고 있다.
*
* <p>버전마다 8 이 박힌 자리가 다르다. 믹스인은 아는 자리를 모두 노리되 하나도 못 찾으면
* 조용히 물러나고, 그 경우 게임은 바닐라 8인 그대로 돈다. 즉 jar 는 하나이고, 올릴 수 있는
* 버전에서만 30 이 된다. 자세한 근거는 docs/DESIGN.md 의 "최대 인원" 절.
*/
public final class MaxPlayers {
/** 바닐라 통합 서버의 하드코딩 값. 1.3 부터 지금(26.3)까지 바뀐 적이 없다. */
public static final int VANILLA = 8;
public static final int RAISED = 30;
private static volatile boolean raised;
private MaxPlayers() {}
/**
* 믹스인이 게임 코드의 상수 8 자리에서 부른다. 8 이 아닌 값이 들어오면 손대지 않는다 —
* 같은 상수를 엉뚱한 용도로 쓰는 코드가 미래에 생겨도 그 값을 망가뜨리지 않기 위해서다.
*/
public static int replace(int vanilla) {
if (vanilla != VANILLA) return vanilla;
if (!raised) {
raised = true;
Log.info("LAN 최대 인원을 " + VANILLA + " → " + RAISED + " 명으로 올렸다");
}
return RAISED;
}
/** 이번 실행에서 확장이 실제로 한 번이라도 적용됐는가. 월드를 열기 전에는 false 다. */
public static boolean raised() {
return raised;
}
}

View File

@@ -0,0 +1,49 @@
package kr.tkrmagid.mcp2p.mixin;
import kr.tkrmagid.mcp2p.MaxPlayers;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.Constant;
import org.spongepowered.asm.mixin.injection.ModifyConstant;
/**
* 이 프로젝트의 유일한 믹스인. "LAN 에 공개" 의 상수 8 을 {@link MaxPlayers#RAISED} 로 바꾼다.
*
* <p>마인크래프트 타입을 하나도 참조하지 않는다. 대상은 전부 문자열이고 핸들러 시그니처는
* {@code int -> int} 라서, 이 클래스의 상수 풀에는 마인크래프트 타입 서술자가 없다
* ({@code ArchitectureTest} 가 강제). 그래서 단일 jar 원칙이 유지된다.
*
* <p>8 이 박힌 자리는 세 곳이고, 실행 중인 버전에는 그중 정확히 한 곳만 존재한다.
* 실측(바닐라 클라이언트 jar 바이트코드)으로 확인한 표:
*
* <pre>
* 버전 이름 체계 위치
* 1.17 ~ 1.21.8 intermediary IntegratedPlayerList 생성자 (class_1130.&lt;init&gt;) → PlayerList.maxPlayers 필드
* 1.21.9 ~ 1.21.11 intermediary IntegratedServer.getMaxPlayers (class_1132.method_3802) 의 return 8
* 26.1 ~ 공식 이름 net.minecraft.client.server.IntegratedServer.getMaxPlayers 의 return 8
* </pre>
*
* <p>{@link Pseudo} 라서 존재하지 않는 대상 클래스는 건너뛰고, {@code require = 0} 이라서 대상
* 메서드나 상수를 못 찾아도 실패하지 않는다. 미래 버전에서 자리가 바뀌면 이 믹스인은 아무것도
* 하지 않고 게임은 바닐라 8인으로 돈다 — 크래시 대신 기능만 빠진다.
*
* <p>1.21.8 이하에서 8 은 {@code super(...)} 호출의 인자라 {@code this} 가 아직 없다.
* 그래서 핸들러가 static 이어야 한다.
*/
@Pseudo
@Mixin(targets = {
"net.minecraft.class_1130", // IntegratedPlayerList (intermediary)
"net.minecraft.class_1132", // IntegratedServer (intermediary)
"net.minecraft.client.server.IntegratedServer", // IntegratedServer (26.1+ 공식 이름)
}, remap = false)
abstract class LanMaxPlayersMixin {
@ModifyConstant(
method = {"<init>", "method_3802", "getMaxPlayers"},
constant = @Constant(intValue = MaxPlayers.VANILLA),
require = 0,
remap = false)
private static int mcp2p$raiseLanLimit(int vanilla) {
return MaxPlayers.replace(vanilla);
}
}

View File

@@ -83,6 +83,7 @@ final class Page {
<dt>STUN</dt><dd id="s-stun">-</dd>
<dt>호스트</dt><dd id="s-host">-</dd>
<dt>참가자 수</dt><dd id="s-players">-</dd>
<dt>최대 인원</dt><dd id="s-max">-</dd>
<dt>참가 상태</dt><dd id="s-join">-</dd>
</dl>
</section>
@@ -118,6 +119,9 @@ final class Page {
: "중지";
$("s-host").className = s.hostArmed ? (s.inviteCode !== "null" ? "ok" : "warn") : "";
$("s-players").textContent = s.players;
$("s-max").textContent = s.maxPlayersRaised
? s.maxPlayers + "명"
: s.maxPlayers + "명 (월드에 들어간 뒤에도 8명이면 이 버전은 확장 미지원)";
const hasCode = s.inviteCode && s.inviteCode !== "null";
$("codeBox").style.display = hasCode ? "block" : "none";

View File

@@ -1,6 +1,7 @@
package kr.tkrmagid.mcp2p.ui;
import kr.tkrmagid.mcp2p.Log;
import kr.tkrmagid.mcp2p.MaxPlayers;
import kr.tkrmagid.mcp2p.Session;
import java.io.BufferedInputStream;
@@ -171,6 +172,8 @@ public final class WebConsole implements AutoCloseable {
sb.append(",\"hostArmed\":").append(session.isHostArmed());
sb.append(",\"inviteCode\":\"").append(escape(String.valueOf(session.hostInviteCode()))).append('"');
sb.append(",\"players\":").append(session.hostPlayerCount());
sb.append(",\"maxPlayers\":").append(MaxPlayers.raised() ? MaxPlayers.RAISED : MaxPlayers.VANILLA);
sb.append(",\"maxPlayersRaised\":").append(MaxPlayers.raised());
sb.append(",\"joined\":").append(session.isJoined());
sb.append(",\"joinRoom\":\"").append(escape(String.valueOf(session.joinRoomId()))).append('"');
sb.append(",\"joinRelay\":\"").append(escape(String.valueOf(session.joinUsesRelay()))).append('"');

View File

@@ -3,7 +3,7 @@
"id": "mc_p2p",
"version": "${version}",
"name": "mc_p2p_mod",
"description": "초대코드 하나로 싱글 월드를 친구와 함께 플레이하는 P2P 모드. 마인크래프트 클래스를 참조하지 않아 모든 버전에서 같은 jar 하나로 동작한다.",
"description": "초대코드 하나로 싱글 월드를 친구와 함께 플레이하는 P2P 모드. 마인크래프트 클래스를 참조하지 않아 모든 버전에서 같은 jar 하나로 동작한다. LAN 최대 인원을 30명으로 올린다(올릴 수 없는 버전은 바닐라 8명).",
"authors": ["tkrmagid"],
"contact": {
"sources": "https://git.tkrmagid.kr/tkrmagid/mc_p2p_mod"
@@ -13,6 +13,7 @@
"entrypoints": {
"client": ["kr.tkrmagid.mcp2p.P2PMod"]
},
"mixins": ["mc_p2p.mixins.json"],
"depends": {
"fabricloader": ">=0.14.0"
}

View File

@@ -0,0 +1,8 @@
{
"required": false,
"minVersion": "0.8",
"package": "kr.tkrmagid.mcp2p.mixin",
"compatibilityLevel": "JAVA_17",
"client": ["LanMaxPlayersMixin"],
"injectors": { "defaultRequire": 0 }
}

View File

@@ -57,6 +57,28 @@ class ArchitectureTest {
"로더 API 참조가 진입점 밖으로 번졌다(다른 로더로 옮기기 어려워진다): " + offenders);
}
@Test
void 믹스인은_전용_패키지에만_있고_게임_타입을_서술하지_않는다() throws IOException {
// 믹스인은 LAN 최대 인원 하나뿐이다. 대상은 점 표기 문자열("net.minecraft.class_1132")
// 이라 위의 "net/minecraft" 검사에 걸리지 않는다. 그래서 여기서 따로 못 박는다 —
// 게임 타입 서술자(Lnet/minecraft/...)가 하나라도 생기면 그 믹스인은 한쪽 이름 체계에서만
// 링크되고, 단일 jar 가 깨진다.
int mixins = 0;
List<String> leaked = new ArrayList<>();
for (Path classFile : compiledClasses()) {
boolean inMixinPackage = classFile.toString().replace('\\', '/').contains("/mcp2p/mixin/");
if (inMixinPackage) {
mixins++;
assertTrue(!contains(classFile, "Lnet/minecraft") && !contains(classFile, "net/fabricmc"),
classFile.getFileName() + " 가 게임/로더 타입을 직접 참조한다");
} else if (contains(classFile, "org/spongepowered")) {
leaked.add(classFile.getFileName().toString());
}
}
assertEquals(1, mixins, "믹스인 클래스 수가 예상과 다르다");
assertTrue(leaked.isEmpty(), "Mixin API 가 믹스인 패키지 밖으로 번졌다: " + leaked);
}
@Test
void 중계_서버는_로더나_게임에_의존하지_않는다() throws IOException {
// 시그널링 서버는 게임 없이 VPS 에서 단독 실행돼야 한다.

View File

@@ -0,0 +1,17 @@
package kr.tkrmagid.mcp2p;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MaxPlayersTest {
@Test
void 바닐라_8만_30으로_바꾸고_나머지_값은_건드리지_않는다() {
assertEquals(30, MaxPlayers.replace(8));
assertTrue(MaxPlayers.raised());
assertEquals(7, MaxPlayers.replace(7));
assertEquals(20, MaxPlayers.replace(20));
}
}

105
tools/lan-limit-probe/probe.py Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""LAN 최대 인원 믹스인을 실제 Fabric Loader + 바닐라 클라이언트 jar 위에서 확인한다.
python3 tools/lan-limit-probe/probe.py [--loader 0.19.5] [--java java] 1.21.11 26.3 ...
바닐라 jar 와 라이브러리를 PROBE_HOME(기본 ~/.cache/mcp2p-lan-limit-probe)에 내려받는다.
버전당 수십~백 MB 다. 모드 jar 는 build/libs 에서 가져오므로 먼저 ./gradlew build 를 돌린다.
게임 창은 뜨지 않는다 — preLaunch 에서 확인하고 바로 끝난다.
1.20.1 이하를 구버전 로더(0.14.x)로 돌릴 때는 --java 로 JDK 17 을 지정한다
(구버전 로더의 ASM 이 최신 JDK 클래스 파일을 못 읽는다).
"""
import argparse, glob, json, os, re, shutil, subprocess, sys, urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
HOME = os.environ.get('PROBE_HOME', os.path.expanduser('~/.cache/mcp2p-lan-limit-probe'))
def fetch(url, path):
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
urllib.request.urlretrieve(url, path + '.part')
os.replace(path + '.part', path)
return path
def classpath(mc, loader):
manifest = json.load(urllib.request.urlopen('https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'))
meta = json.load(urllib.request.urlopen(next(v['url'] for v in manifest['versions'] if v['id'] == mc)))
cp, seen = [], set()
profile = json.load(urllib.request.urlopen(
f'https://meta.fabricmc.net/v2/versions/loader/{mc}/{loader}/profile/json'))
for lib in profile['libraries']:
g, a, v = lib['name'].split(':')
seen.add((g, a))
rel = f"{g.replace('.', '/')}/{a}/{v}/{a}-{v}.jar"
cp.append(fetch(lib['url'].rstrip('/') + '/' + rel, f'{HOME}/libs/{rel}'))
for lib in meta['libraries']:
art = lib.get('downloads', {}).get('artifact')
# 로더가 이미 주는 것(ASM 등)은 빼야 한다. 중복이면 Knot 이 시작을 거부한다.
if not art or 'natives' in art['path'] or tuple(lib['name'].split(':')[:2]) in seen:
continue
cp.append(fetch(art['url'], f"{HOME}/libs/{art['path']}"))
cp.append(fetch(meta['downloads']['client']['url'], f'{HOME}/{mc}/client.jar'))
return cp
def build_probe(loader_jar, javac):
out = f'{HOME}/probe-build'
shutil.rmtree(out, ignore_errors=True)
os.makedirs(out)
subprocess.run([javac, '--release', '17', '-nowarn', '-cp', loader_jar, '-d', out,
f'{HERE}/src/probe/Probe.java'], check=True)
with open(f'{out}/fabric.mod.json', 'w') as f:
json.dump({'schemaVersion': 1, 'id': 'mcp2p_probe', 'version': '1',
'entrypoints': {'preLaunch': ['probe.Probe']}}, f)
jar = f'{HOME}/probe.jar'
subprocess.run(['jar', 'cf', jar, '-C', out, '.'], check=True)
return jar
def main():
ap = argparse.ArgumentParser()
ap.add_argument('versions', nargs='+')
ap.add_argument('--loader', default='0.19.5')
ap.add_argument('--java', default='java')
ap.add_argument('--mod', help='검사할 모드 jar (기본: build/libs 의 산출물)')
args = ap.parse_args()
mods = [args.mod] if args.mod else \
[p for p in glob.glob(f'{REPO}/build/libs/mc_p2p_mod-*.jar') if 'rendezvous' not in p]
if not mods:
sys.exit('build/libs 에 모드 jar 가 없다 — ./gradlew build 먼저')
javac = os.path.join(os.path.dirname(shutil.which(args.java) or args.java), 'javac')
failed = False
for mc in args.versions:
cp = classpath(mc, args.loader)
probe = build_probe(next(p for p in cp if '/fabric-loader/' in p), javac)
game = f'{HOME}/run/{mc}-{args.loader}'
shutil.rmtree(f'{game}/mods', ignore_errors=True)
os.makedirs(f'{game}/mods')
shutil.copy(mods[0], f'{game}/mods/')
shutil.copy(probe, f'{game}/mods/')
log = subprocess.run(
[args.java, '-Xmx2g', '-cp', os.pathsep.join(cp), 'net.fabricmc.loader.impl.launch.knot.KnotClient',
'--gameDir', game, '--version', mc, '--accessToken', '0'],
cwd=game, capture_output=True, text=True, timeout=600).stdout
with open(f'{game}/log.txt', 'w') as f:
f.write(log)
lines = [re.sub(r'^.*?PROBE ', '', l) for l in log.splitlines() if 'PROBE ' in l]
values = [int(m.group(1)) for l in lines for m in [re.search(r'(?:getMaxPlayers\(\)|maxPlayers) = (\d+)', l)] if m]
ok = 'raised=true' in lines and all(v == 30 for v in values)
failed |= not ok
print(f"{'PASS' if ok else 'FAIL'} {mc} (loader {args.loader})")
for l in lines:
print(' ' + l)
if not lines:
print(f' PROBE 출력 없음 — {game}/log.txt 확인')
sys.exit(1 if failed else 0)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,123 @@
package probe;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
/**
* 검증 전용 모드. 게임 메인 직전(preLaunch)에 돌아서, Fabric 이 mc_p2p 의 믹스인을 적용한
* 실제 대상 클래스를 불러 최대 인원이 30 인지 확인하고 곧바로 JVM 을 끝낸다. 화면이 필요 없다.
*
* <p>출력은 "PROBE " 로 시작하는 줄이다. probe.py 가 이걸 읽어 판정한다.
*/
public final class Probe implements net.fabricmc.loader.api.entrypoint.PreLaunchEntrypoint {
@Override
public void onPreLaunch() {
try {
run();
} catch (Throwable t) {
System.out.println("PROBE ERROR " + t);
t.printStackTrace(System.out);
}
System.out.flush();
Runtime.getRuntime().halt(0);
}
private static void run() throws Throwable {
ClassLoader cl = Probe.class.getClassLoader();
var loader = net.fabricmc.loader.api.FabricLoader.getInstance();
System.out.println("PROBE ns=" + loader.getMappingResolver().getCurrentRuntimeNamespace()
+ " mc=" + loader.getModContainer("minecraft").get().getMetadata().getVersion().getFriendlyString());
// 대상 클래스의 정적 초기화가 레지스트리를 요구하므로 전용 서버 main 과 같은 순서로 부트스트랩한다.
String[][] boot = {
{"net.minecraft.class_155", "method_36208", "net.minecraft.class_2966", "method_12851"},
{"net.minecraft.SharedConstants", "tryDetectVersion", "net.minecraft.server.Bootstrap", "bootStrap"}};
for (String[] b : boot) {
if (find(cl, b[0]) == null) continue;
Class.forName(b[0], true, cl).getMethod(b[1]).invoke(null);
Class.forName(b[2], true, cl).getMethod(b[3]).invoke(null);
}
Method raised = Class.forName("kr.tkrmagid.mcp2p.MaxPlayers", true, cl).getMethod("raised");
Field uf = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
uf.setAccessible(true);
Object unsafe = uf.get(null);
Method alloc = unsafe.getClass().getMethod("allocateInstance", Class.class);
// 1.21.9 이상: IntegratedServer.getMaxPlayers() 를 직접 부른다.
for (String n : new String[]{"net.minecraft.class_1132", "net.minecraft.client.server.IntegratedServer"}) {
Class<?> c = find(cl, n);
if (c == null) continue;
for (String mn : new String[]{"method_3802", "getMaxPlayers"}) {
try {
Method m = c.getDeclaredMethod(mn);
System.out.println("PROBE IntegratedServer.getMaxPlayers() = " + m.invoke(alloc.invoke(unsafe, c)));
} catch (NoSuchMethodException ignored) {
// 1.21.8 이하에는 이 클래스에 선언돼 있지 않다.
}
}
}
// 1.21.8 이하: IntegratedPlayerList 를 만들어 PlayerList.maxPlayers 필드를 본다.
for (String n : new String[]{"net.minecraft.class_1130", "net.minecraft.client.server.IntegratedPlayerList"}) {
Class<?> c = find(cl, n);
if (c == null) continue;
Constructor<?> ctor = c.getDeclaredConstructors()[0];
ctor.setAccessible(true);
Object[] args = new Object[ctor.getParameterCount()];
for (int i = 0; i < args.length; i++) {
try {
args[i] = alloc.invoke(unsafe, ctor.getParameterTypes()[i]);
fillEmptyCollections(args[i]);
} catch (Throwable ignored) {
// 인터페이스 등은 null 로 둔다.
}
}
try {
Object list = ctor.newInstance(args);
for (Class<?> k = c.getSuperclass(); k != null; k = k.getSuperclass()) {
for (String fn : new String[]{"field_14347", "maxPlayers"}) {
try {
Field f = k.getDeclaredField(fn);
f.setAccessible(true);
System.out.println("PROBE PlayerList.maxPlayers = " + f.getInt(list));
} catch (NoSuchFieldException ignored) {
// 1.21.9 이상에는 이 필드가 없다.
}
}
}
} catch (Throwable t) {
// 가짜 인자라 생성자 뒷부분에서 터질 수 있다. 8 → 30 치환은 super() 전에 끝난다.
System.out.println("PROBE IntegratedPlayerList ctor threw " + (t.getCause() != null ? t.getCause() : t));
}
}
System.out.println("PROBE raised=" + raised.invoke(null));
}
private static Class<?> find(ClassLoader cl, String name) {
try {
return Class.forName(name, false, cl);
} catch (Throwable t) {
return null;
}
}
private static void fillEmptyCollections(Object o) {
for (Class<?> k = o.getClass(); k != null && k != Object.class; k = k.getSuperclass()) {
for (Field f : k.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
try {
f.setAccessible(true);
if (f.get(o) != null) continue;
if (f.getType() == java.util.Map.class) f.set(o, new java.util.LinkedHashMap<>());
else if (f.getType() == java.util.List.class) f.set(o, new java.util.ArrayList<>());
} catch (Throwable ignored) {
// 접근 불가 필드는 건너뛴다.
}
}
}
}
}