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>
This commit is contained in:
EJClaw
2026-10-05 04:07:06 +09:00
parent 40b8894a98
commit 16a44ec4bc
13 changed files with 476 additions and 12 deletions

View File

@@ -3,7 +3,7 @@
초대코드 하나로 싱글플레이 월드를 친구와 함께 플레이하는 마인크래프트 P2P 모드. 초대코드 하나로 싱글플레이 월드를 친구와 함께 플레이하는 마인크래프트 P2P 모드.
**jar 파일 하나가 모든 마인크래프트 버전에서 동작한다.** 1.21 용, 1.21.8 용, 26.3 용을 따로 **jar 파일 하나가 모든 마인크래프트 버전에서 동작한다.** 1.21 용, 1.21.8 용, 26.3 용을 따로
내려받지 않는다. 버전별 빌드도, 매핑도, 믹스인도 없다. 어떻게 가능한지는 내려받지 않는다. 버전별 빌드도, 매핑도 없다. 어떻게 가능한지는
[docs/DESIGN.md](docs/DESIGN.md) 에 근거와 함께 적어 두었다. [docs/DESIGN.md](docs/DESIGN.md) 에 근거와 함께 적어 두었다.
| | | | | |
@@ -12,7 +12,8 @@
| 네이티브 라이브러리 | 0개 | | 네이티브 라이브러리 | 0개 |
| jar 크기 | 약 0.71 MB | | jar 크기 | 약 0.71 MB |
| 지원 버전 | Fabric 이 도는 모든 버전 (JVM 17 이상) | | 지원 버전 | Fabric 이 도는 모든 버전 (JVM 17 이상) |
| 믹스인 | 없음 | | 최대 인원 | 30명 (확장할 수 없는 버전은 바닐라 8명) |
| 믹스인 | 1개 — LAN 최대 인원 전용, 안 맞으면 스스로 빠진다 |
| 마인크래프트 클래스 참조 | 0개 (테스트로 강제) | | 마인크래프트 클래스 참조 | 0개 (테스트로 강제) |
--- ---
@@ -27,8 +28,8 @@
[mc_p2p] 준비 완료 — 조작은 http://127.0.0.1:25585 에서 한다 [mc_p2p] 준비 완료 — 조작은 http://127.0.0.1:25585 에서 한다
``` ```
Fabric API 는 필요하지 않다. 다른 모드와 충돌할 여지도 사실상 없다 — 이 모드는 게임 코드를 Fabric API 는 필요하지 않다. 게임 코드에서 건드리는 곳은 "LAN 에 공개" 의 최대 인원 상수
한 줄도 건드리지 않는다. 하나뿐이다(아래 "최대 인원").
--- ---
@@ -93,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: ...)
```
---
## 연결 방식 ## 연결 방식
경로를 동시에 시도해서 되는 것을 쓴다. 경로를 동시에 시도해서 되는 것을 쓴다.
@@ -165,7 +197,7 @@ Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고
솔직하게 적는다. 솔직하게 적는다.
**기계로 확인된 것** (`./gradlew test`, 57개): **기계로 확인된 것** (`./gradlew test`, 59개):
- 터널 전 구간 — 펀칭 프로브, 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조, - 터널 전 구간 — 펀칭 프로브, 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조,
TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다. TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다.
@@ -176,13 +208,22 @@ Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고
- LAN 비콘을 실제 멀티캐스트로 쏘고 받아 포트를 읽는다. - LAN 비콘을 실제 멀티캐스트로 쏘고 받아 포트를 읽는다.
- 시그널링 프로토콜 — 등록, 참가, 방 중복, 호스트 이탈 시 방 정리, 잘못된 입력. - 시그널링 프로토콜 — 등록, 참가, 방 중복, 호스트 이탈 시 방 정리, 잘못된 입력.
- 웹 콘솔 — 실제 HTTP 요청/응답, 루프백 전용 바인딩. - 웹 콘솔 — 실제 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 가 없다. - 실제 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") 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 의 전송 계층 비교 참고). // 순수 자바 QUIC. 네이티브 라이브러리 0개 (docs/DESIGN.md 의 전송 계층 비교 참고).
implementation("tech.kwik:kwik:0.11") implementation("tech.kwik:kwik:0.11")

View File

@@ -84,7 +84,7 @@ GUI 코드가 한 줄도 필요 없다.
- 버전별 빌드 17개 → 1개 - 버전별 빌드 17개 → 1개
- Stonecutter 조건부 주석 (기존 구현은 GUI 한 파일에만 96개) → 0개 - Stonecutter 조건부 주석 (기존 구현은 GUI 한 파일에만 96개) → 0개
- 믹스인 14개 → 0개. 다른 모드와 충돌할 지점도 함께 사라졌다. - 믹스인 14개 → 1개(LAN 최대 인원 전용, 7절). 다른 모드와 충돌할 지점도 거의 사라졌다.
- Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸 - Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸
- 게임 API 를 안 건드리므로 새 마인크래프트 버전이 나와도 할 일이 없다 - 게임 API 를 안 건드리므로 새 마인크래프트 버전이 나와도 할 일이 없다
@@ -219,12 +219,61 @@ 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· 2. **실제 NAT 뒤에서의 홀펀칭 성공률 측정.** 같은 기기 안 테스트에는 NAT 가 없다. 대칭 NAT·
CGNAT 비율에 따라 중계 의존도가 결정되므로 숫자를 봐야 다음 판단을 할 수 있다. CGNAT 비율에 따라 중계 의존도가 결정되므로 숫자를 봐야 다음 판단을 할 수 있다.
3. **시그널링 TLS.** 지금은 평문 TCP 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수 3. **시그널링 TLS.** 지금은 평문 TCP 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수

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

View File

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

View File

@@ -3,7 +3,7 @@
"id": "mc_p2p", "id": "mc_p2p",
"version": "${version}", "version": "${version}",
"name": "mc_p2p_mod", "name": "mc_p2p_mod",
"description": "초대코드 하나로 싱글 월드를 친구와 함께 플레이하는 P2P 모드. 마인크래프트 클래스를 참조하지 않아 모든 버전에서 같은 jar 하나로 동작한다.", "description": "초대코드 하나로 싱글 월드를 친구와 함께 플레이하는 P2P 모드. 마인크래프트 클래스를 참조하지 않아 모든 버전에서 같은 jar 하나로 동작한다. LAN 최대 인원을 30명으로 올린다(올릴 수 없는 버전은 바닐라 8명).",
"authors": ["tkrmagid"], "authors": ["tkrmagid"],
"contact": { "contact": {
"sources": "https://git.tkrmagid.kr/tkrmagid/mc_p2p_mod" "sources": "https://git.tkrmagid.kr/tkrmagid/mc_p2p_mod"
@@ -13,6 +13,7 @@
"entrypoints": { "entrypoints": {
"client": ["kr.tkrmagid.mcp2p.P2PMod"] "client": ["kr.tkrmagid.mcp2p.P2PMod"]
}, },
"mixins": ["mc_p2p.mixins.json"],
"depends": { "depends": {
"fabricloader": ">=0.14.0" "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); "로더 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 @Test
void 중계_서버는_로더나_게임에_의존하지_않는다() throws IOException { void 중계_서버는_로더나_게임에_의존하지_않는다() throws IOException {
// 시그널링 서버는 게임 없이 VPS 에서 단독 실행돼야 한다. // 시그널링 서버는 게임 없이 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) {
// 접근 불가 필드는 건너뛴다.
}
}
}
}
}