feat: LAN 최대 인원 8 → 30 (단일 jar, 확장 불가 버전은 8 유지)
Some checks failed
build / build (push) Has been cancelled
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:
55
README.md
55
README.md
@@ -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번째 이후 플레이어를 접속시켜 보지는 않았다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수
|
||||||
|
|||||||
38
src/main/java/kr/tkrmagid/mcp2p/MaxPlayers.java
Normal file
38
src/main/java/kr/tkrmagid/mcp2p/MaxPlayers.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.<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
|
||||||
|
* </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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('"');
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/main/resources/mc_p2p.mixins.json
Normal file
8
src/main/resources/mc_p2p.mixins.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"required": false,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "kr.tkrmagid.mcp2p.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_17",
|
||||||
|
"client": ["LanMaxPlayersMixin"],
|
||||||
|
"injectors": { "defaultRequire": 0 }
|
||||||
|
}
|
||||||
@@ -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 에서 단독 실행돼야 한다.
|
||||||
|
|||||||
17
src/test/java/kr/tkrmagid/mcp2p/MaxPlayersTest.java
Normal file
17
src/test/java/kr/tkrmagid/mcp2p/MaxPlayersTest.java
Normal 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
105
tools/lan-limit-probe/probe.py
Executable 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()
|
||||||
123
tools/lan-limit-probe/src/probe/Probe.java
Normal file
123
tools/lan-limit-probe/src/probe/Probe.java
Normal 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) {
|
||||||
|
// 접근 불가 필드는 건너뛴다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user