diff --git a/README.md b/README.md index 6a85397..8adaa56 100644 --- a/README.md +++ b/README.md @@ -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 에 공개" 의 최대 인원 상수 +하나뿐이다(아래 "최대 인원"). --- @@ -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 핸드셰이크, 인증서 지문 고정, 방 비밀 대조, TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다. @@ -176,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번째 이후 플레이어를 접속시켜 보지는 않았다. --- diff --git a/build.gradle.kts b/build.gradle.kts index a5f26c6..366ec48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7b1c18..b7412a1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -84,7 +84,7 @@ GUI 코드가 한 줄도 필요 없다. - 버전별 빌드 17개 → 1개 - Stonecutter 조건부 주석 (기존 구현은 GUI 한 파일에만 96개) → 0개 -- 믹스인 14개 → 0개. 다른 모드와 충돌할 지점도 함께 사라졌다. +- 믹스인 14개 → 1개(LAN 최대 인원 전용, 7절). 다른 모드와 충돌할 지점도 거의 사라졌다. - Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸 - 게임 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.` — `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)` — 메서드 선택자 ``, `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 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수 diff --git a/src/main/java/kr/tkrmagid/mcp2p/MaxPlayers.java b/src/main/java/kr/tkrmagid/mcp2p/MaxPlayers.java new file mode 100644 index 0000000..91d4f83 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/MaxPlayers.java @@ -0,0 +1,38 @@ +package kr.tkrmagid.mcp2p; + +/** + * "LAN 에 공개" 의 8인 제한을 30인으로 올린다. 실제 치환은 {@code mixin.LanMaxPlayersMixin} 이 + * 하고, 여기는 그 값과 "실제로 적용됐는가" 만 들고 있다. + * + *

버전마다 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; + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/mixin/LanMaxPlayersMixin.java b/src/main/java/kr/tkrmagid/mcp2p/mixin/LanMaxPlayersMixin.java new file mode 100644 index 0000000..b8a34e0 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/mixin/LanMaxPlayersMixin.java @@ -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} 로 바꾼다. + * + *

마인크래프트 타입을 하나도 참조하지 않는다. 대상은 전부 문자열이고 핸들러 시그니처는 + * {@code int -> int} 라서, 이 클래스의 상수 풀에는 마인크래프트 타입 서술자가 없다 + * ({@code ArchitectureTest} 가 강제). 그래서 단일 jar 원칙이 유지된다. + * + *

8 이 박힌 자리는 세 곳이고, 실행 중인 버전에는 그중 정확히 한 곳만 존재한다. + * 실측(바닐라 클라이언트 jar 바이트코드)으로 확인한 표: + * + *

+ * 버전            이름 체계       위치
+ * 1.17 ~ 1.21.8   intermediary   IntegratedPlayerList 생성자 (class_1130.<init>) → 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
+ * 
+ * + *

{@link Pseudo} 라서 존재하지 않는 대상 클래스는 건너뛰고, {@code require = 0} 이라서 대상 + * 메서드나 상수를 못 찾아도 실패하지 않는다. 미래 버전에서 자리가 바뀌면 이 믹스인은 아무것도 + * 하지 않고 게임은 바닐라 8인으로 돈다 — 크래시 대신 기능만 빠진다. + * + *

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 = {"", "method_3802", "getMaxPlayers"}, + constant = @Constant(intValue = MaxPlayers.VANILLA), + require = 0, + remap = false) + private static int mcp2p$raiseLanLimit(int vanilla) { + return MaxPlayers.replace(vanilla); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java b/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java index 354c4ba..4983f36 100644 --- a/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java +++ b/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java @@ -83,6 +83,7 @@ final class Page {

STUN
-
호스트
-
참가자 수
-
+
최대 인원
-
참가 상태
-
@@ -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"; diff --git a/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java b/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java index f4626f3..07dafa7 100644 --- a/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java +++ b/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java @@ -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('"'); diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 2673bcf..35e4178 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -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" } diff --git a/src/main/resources/mc_p2p.mixins.json b/src/main/resources/mc_p2p.mixins.json new file mode 100644 index 0000000..b3721ee --- /dev/null +++ b/src/main/resources/mc_p2p.mixins.json @@ -0,0 +1,8 @@ +{ + "required": false, + "minVersion": "0.8", + "package": "kr.tkrmagid.mcp2p.mixin", + "compatibilityLevel": "JAVA_17", + "client": ["LanMaxPlayersMixin"], + "injectors": { "defaultRequire": 0 } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java b/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java index b85f192..e2846ce 100644 --- a/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java +++ b/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java @@ -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 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 에서 단독 실행돼야 한다. diff --git a/src/test/java/kr/tkrmagid/mcp2p/MaxPlayersTest.java b/src/test/java/kr/tkrmagid/mcp2p/MaxPlayersTest.java new file mode 100644 index 0000000..7a7f211 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/MaxPlayersTest.java @@ -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)); + } +} diff --git a/tools/lan-limit-probe/probe.py b/tools/lan-limit-probe/probe.py new file mode 100755 index 0000000..d8da0d6 --- /dev/null +++ b/tools/lan-limit-probe/probe.py @@ -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() diff --git a/tools/lan-limit-probe/src/probe/Probe.java b/tools/lan-limit-probe/src/probe/Probe.java new file mode 100644 index 0000000..b1525de --- /dev/null +++ b/tools/lan-limit-probe/src/probe/Probe.java @@ -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 을 끝낸다. 화면이 필요 없다. + * + *

출력은 "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) { + // 접근 불가 필드는 건너뛴다. + } + } + } + } +}