Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Build

on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- uses: gradle/actions/setup-gradle@v4

- name: Build
run: ./gradlew jar

- uses: actions/upload-artifact@v4
with:
name: mcpc-java17-agent
path: build/libs/mcpc-java17-agent.jar

- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: build/libs/mcpc-java17-agent.jar
generate_release_notes: true
80 changes: 59 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,74 +1,112 @@
# MCPC+ Java 8 Agent
# MCPC+ Java 17 Agent

Lance un serveur **MCPC+ / Cauldron 1.6.4** sous **Java 8**, sans modifier le jar du serveur ni les mods.
Lance un serveur **MCPC+ / Cauldron 1.6.4** sous **Java 17**, sans modifier le jar du serveur ni les mods.

Ces serveurs refusent de démarrer sous Java 8, ou plantent pendant le chargement de Forge. Cet agent lève les trois blocages au démarrage.
Ces serveurs refusent de démarrer sur autre chose que Java 7, puis plantent pendant le chargement de launchwrapper et de Forge, car ils s'appuient sur des comportements de Java 7/8 qui n'existent plus en Java 17. Cet agent lève ces blocages au démarrage.

## Installation

1. Récupérez `mcpc-java8-agent.jar` depuis les [releases](../../releases), ou compilez-le (voir plus bas).
1. Récupérez `mcpc-java17-agent.jar` depuis les [releases](../../releases), ou compilez-le (voir plus bas).
2. Placez-le où vous voulez, par exemple à côté du jar du serveur.
3. Ajoutez `-javaagent:` à votre ligne de lancement, **avant** `-jar` :

```sh
java -javaagent:mcpc-java8-agent.jar -Xmx4G -jar mcpc.jar nogui
java -javaagent:mcpc-java17-agent.jar -Xmx4G -jar mcpc.jar nogui
```

C'est tout. Aucun fichier de configuration, aucune modification du serveur ou des mods.
C'est tout. Aucun `--add-opens`, aucun fichier de configuration, aucune modification du serveur ou des mods.

### Exemple de start.sh

```sh
#!/bin/sh
java -javaagent:mcpc-java8-agent.jar \
java -javaagent:mcpc-java17-agent.jar \
-Xms2G -Xmx4G \
-jar mcpc.jar nogui
```

### Options JVM à retirer

Certaines options courantes des anciens scripts de lancement n'existent plus en Java 17 :

- `-XX:+UseConcMarkSweepGC` et les options `CMS*` : supprimées, **la JVM refuse de démarrer**. Retirez-les (G1, le GC par défaut, convient très bien).
- `-XX:PermSize` / `-XX:MaxPermSize` : ignorées depuis Java 8, vous pouvez les retirer.

### Vérifier que ça marche

Au démarrage, la console affiche :

```
[mcpc-j8] Java 8 detected (1.8.0_412), reporting 1.7.0_80 to bypass the MCPC+ version check.
[mcpc-j8] 4 class patch(es) loaded.
[mcpc-j8] Patched: net/minecraft/launchwrapper/Launch
[mcpc-j17] Java 17.0.x detected, reporting 1.7.0_80 to bypass the MCPC+ version check.
[mcpc-j17] Opened 741 JDK packages to the server.
[mcpc-j17] Reflection filters removed.
[mcpc-j17] 1 class patch(es) loaded.
[mcpc-j17] Patched: net/minecraft/launchwrapper/Launch
[mcpc-j17] ASM 4 detected, upgrading it to the bundled ASM.
[mcpc-j17] LaunchClassLoader delegates 241 platform packages to the JDK.
[mcpc-j17] Redirected sun.reflect in: net/minecraftforge/common/EnumHelper
```

Si vous ne voyez aucune ligne `[mcpc-j8]`, l'agent n'est pas chargé : vérifiez que `-javaagent:` est bien **avant** `-jar` et que le chemin du jar est correct.
Sur un serveur dont `Launch` ou ASM sont déjà modernisés (voir plus bas), la ligne `Patched: .../Launch` est absente et `Recent ASM detected, keeping the server's ASM classes.` remplace la ligne sur ASM : c'est normal.

Si vous ne voyez aucune ligne `[mcpc-j17]`, l'agent n'est pas chargé : vérifiez que `-javaagent:` est bien **avant** `-jar` et que le chemin du jar est correct.

## Ce que l'agent corrige

**1. Le refus explicite de Java 8.** `FMLLaunchHandler` teste `java.version` et arrête le serveur avec le message `Java 8 is not supported on MCPC+8.11! Please run MCPC+ using Java 7.`
**1. Le refus de toute version autre que Java 7.** `FMLLaunchHandler` teste `java.version` et arrête le serveur (`... is not supported on MCPC+8.11! Please run MCPC+ using Java 7.`).

L'agent présente une version `1.7.x` à cette vérification (inoffensif si le serveur ne fait plus ce test). Passer `-Djava.version=1.7.0_80` sur la ligne de commande **ne fonctionne pas** : la JVM réécrit cette propriété pendant son initialisation. Il faut le faire depuis un agent, qui s'exécute après l'initialisation de la JVM mais avant le `main` du serveur. Un coremod ne peut pas corriger ce point : `FMLLaunchHandler` est exclu du `LaunchClassLoader`.

**2. Le démarrage de launchwrapper.** `Launch` construit son class loader à partir de `(URLClassLoader) getClass().getClassLoader()`. Depuis Java 9, le class loader de l'application n'est plus un `URLClassLoader` : `ClassCastException` immédiate. La version corrigée de `Launch` lit le class path depuis `java.class.path`, comme les versions récentes de launchwrapper.

La version corrigée garde aussi le correctif de la `ConcurrentModificationException` dans `Launch.launch` (liste des tweakers modifiée pendant son parcours).

L'agent présente une version `1.7.x` à cette vérification. Passer `-Djava.version=1.7.0_80` sur la ligne de commande **ne fonctionne pas** : la JVM réécrit cette propriété pendant son initialisation. Il faut le faire depuis un agent, qui s'exécute après l'initialisation de la JVM mais avant le `main` du serveur.
`Launch` n'est remplacé que s'il fait encore ce cast : un `Launch` déjà adapté par le serveur est conservé.

Un coremod ne peut pas corriger ce point : `FMLLaunchHandler` est explicitement exclu du `LaunchClassLoader`, donc hors de portée de tout `IClassTransformer`.
**3. Les classes du JDK hors de `java.base`.** Le `LaunchClassLoader` a pour parent le class loader de bootstrap. En Java 8, celui-ci voyait tout le JDK ; depuis Java 9, des modules comme `java.sql` ou `java.scripting` sont chargés par le *platform class loader*, et des classes comme `javax.sql.DataSource` (Ebean, plugins Bukkit) deviennent introuvables. L'agent fait déléguer ces packages au JDK.

**2. Un plantage dans launchwrapper.** `Launch.launch` parcourt la liste des tweakers tout en la modifiant, ce qui lève une `ConcurrentModificationException` sous Java 8.
**4. L'encapsulation des modules du JDK.** Forge, Bukkit et les mods appellent `setAccessible` sur des classes internes du JDK, ce que Java 17 refuse par défaut (`InaccessibleObjectException`). L'agent ouvre tous les packages du JDK au serveur, l'équivalent d'une longue liste de `--add-opens ...=ALL-UNNAMED`. Il retire aussi les filtres de réflexion ajoutés en Java 12 (qui cachent notamment `Field.modifiers`).

Si votre serveur utilise déjà launchwrapper 1.11 ou supérieur, ce point est déjà réglé de votre côté ; l'agent reste inoffensif.
**5. `sun.reflect.ReflectionFactory`, supprimé en Java 9.** L'`EnumHelper` de Forge s'en sert pour ajouter des valeurs aux enums (biomes, types de créatures, matériaux Bukkit de MCPC+...). Les références à `sun.reflect.ReflectionFactory`, `ConstructorAccessor` et `FieldAccessor` sont redirigées, dans toutes les classes chargées, vers des équivalents fournis par l'agent qui fonctionnent sous Java 17.

**3. Des plantages du déobfuscateur de Forge.** La version d'ASM embarquée dans ces serveurs échoue sur du bytecode produit par Java 8 (`Remapper`, `ClassReader`, `MethodInsnNode`).
**6. ASM 4.** MCPC+ est fourni avec ASM 4.1 (`libraries/org/ow2/asm/asm-all/4.1`), qui ne sait pas lire le bytecode Java 8+ (lambdas, méthodes static/default d'interface...) et ne connaît pas les API récentes (`Opcodes.ASM5` à `ASM9`) qu'utilisent les coremods modernes.

L'agent embarque **ASM 9.7** et le substitue à ASM 4 au chargement. Rien à changer dans `libraries/`. Pour que Forge 1.6.4, MCPC+ et les mods écrits pour ASM 4 continuent de fonctionner, cet ASM 9.7 est adapté au build :

- les classes `Remapping*Adapter`, supprimées après ASM 7.0 et utilisées par Forge, sont reprises d'ASM 7.0 ;
- `Type.getType(String)` accepte un nom interne comme le faisait ASM 4 (Forge s'en sert dans `EventTransformer`) ;
- les visiteurs déclarés avec l'API ASM 4 (le parseur de mods de FML, par exemple) ne lèvent plus `This feature requires ASM5` sur du bytecode récent : l'information est transmise au visiteur suivant, y compris le caractère « interface » des appels de méthodes, indispensable aux méthodes static d'interface.

Un serveur qui fournit déjà un ASM 5 ou plus récent garde le sien.

## Testé sur

Sous OpenJDK 17.0.20, avec un mod de test compilé en Java 8 (lambdas, streams, méthodes static/default d'interface) et contenant un coremod qui utilise l'API `Opcodes.ASM9` :

- MCPC+ 1.6.4 d'origine (Forge 9.11.1.965) avec son ASM 4.1 : le serveur démarre (`Done`), le mod est chargé et actif, sans aucune erreur dans les logs FML. Sans l'agent, le serveur plante dès le lancement (`ClassCastException` dans `Launch`).
- Le build NGServer de MCPC+ 1.6.4, compilé contre ASM 9 : même résultat, qu'il y ait ASM 9 ou l'ASM 4.1 d'origine dans `libraries/`. Sans l'agent, il plante au chargement du monde (`EnumHelper` : `sun.reflect.ReflectionFactory` absent, `java.lang` fermé).
- Un banc reproduisant launchwrapper 1.8 d'origine et l'`EnumHelper` de Forge 1.6.4, sous OpenJDK 17 et 21.

## Portée et limites

L'agent débloque le **lancement**. Il ne rend pas compatibles des mods qui dépendraient d'API supprimées en Java 8 — un cas rare sur cette génération de mods.
L'agent débloque le **lancement**. Il ne rend pas compatibles des mods ou plugins qui dépendraient d'API supprimées du JDK (moteur JavaScript Nashorn, `javax.xml.bind`, `Thread.stop`...) ; ces cas restent à traiter au cas par cas.

Il ne touche à rien d'autre : pas de modification du monde, des mods, ni du comportement du serveur. Si un correctif ne peut pas s'appliquer, la classe d'origine est laissée intacte et le démarrage continue.

Testé sur MCPC+ 8.11 / Cauldron 1.6.4 avec OpenJDK 8 et Zulu 8, en production.
L'agent requiert Java 17 ou plus récent ; il ne se charge pas sous Java 8.

## Compiler

```sh
./gradlew jar
```

Le jar est produit dans `build/libs/mcpc-java8-agent.jar`. Aucune dépendance externe.
Nécessite un JDK 17 ou plus récent. La CI GitHub Actions construit le jar à chaque push ; pousser un tag `vX.Y.Z` publie une release avec le jar attaché. Le jar est produit dans `build/libs/mcpc-java17-agent.jar` et contient ASM (téléchargé depuis Maven Central et adapté au build, voir `build.gradle`) ; il n'a besoin de rien d'autre à l'exécution.

## Contenu

Les fichiers `.bin` sont les classes d'origine de **launchwrapper** et **ASM**, recompilées avec la correction minimale nécessaire. Elles restent sous la licence de leurs projets respectifs : MIT pour launchwrapper, BSD 3-clauses pour ASM.
`Launch.bin` est la classe d'origine de **launchwrapper** (MIT), recompilée avec la correction minimale nécessaire : son constructeur appelle `gg.nationsglory.mcpcj17.LaunchHooks`.

Le jar embarque **ASM** (BSD 3-clauses) : ASM 9.7, les `Remapping*Adapter` d'ASM 7.0, et `org.objectweb.asm.LegacyAsmApi`, ajouté par l'agent.

Le code de l'agent lui-même est publié sous licence MIT.
186 changes: 177 additions & 9 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,26 +1,194 @@
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.Opcodes
import org.objectweb.asm.tree.*

buildscript {
repositories { mavenCentral() }
dependencies { classpath 'org.ow2.asm:asm-tree:9.7' }
}

plugins {
id 'java'
}

group = 'gg.nationsglory'
version = '1.0.0'

sourceCompatibility = '1.7'
targetCompatibility = '1.7'
version = '2.0.0'

compileJava { options.encoding = 'UTF-8' }
compileJava {
options.encoding = 'UTF-8'
options.release = 17
}

repositories { mavenCentral() }

def asmVersion = '9.7'

configurations {
// ASM shipped inside the agent, replacing the ASM 4 of MCPC+ at runtime.
bundledAsm { transitive = false }
// The Remapping*Adapter classes Forge 1.6.4 still uses were removed after ASM 7.0.
legacyAsmCommons { transitive = false }
}

dependencies {
bundledAsm "org.ow2.asm:asm:${asmVersion}"
bundledAsm "org.ow2.asm:asm-tree:${asmVersion}"
bundledAsm "org.ow2.asm:asm-commons:${asmVersion}"
bundledAsm "org.ow2.asm:asm-analysis:${asmVersion}"
bundledAsm "org.ow2.asm:asm-util:${asmVersion}"
legacyAsmCommons 'org.ow2.asm:asm-commons:7.0'
}

/*
* The bundled ASM is patched to behave like the ASM 4 that Forge 1.6.4, MCPC+ and their mods were
* written for:
* - Type.getType(String) accepts an internal name ("net/minecraftforge/event/Event"), as ASM 4
* did; Forge's EventTransformer relies on it. It returns the object type of that name.
* - Visitors declared with the ASM 4 API no longer throw "This feature requires ASM5" when they
* read Java 8+ bytecode (lambdas, interface static methods, type annotations...): the call is
* forwarded to the next visitor, as for any method the visitor does not override.
* - The interface flag of method calls survives ASM 4 visitors (see LegacyAsmApi).
*/
def patchedAsmDir = layout.buildDirectory.dir('patched-asm')
def legacyApi = 'org/objectweb/asm/LegacyAsmApi'
def asmPatches = [
'org/objectweb/asm/Type': { ClassNode node ->
def method = node.methods.find { it.name == 'getTypeInternal' }
def create = method.instructions.find {
it.opcode == Opcodes.NEW && it.desc == 'java/lang/IllegalArgumentException'
}
if (create == null) {
throw new GradleException('ASM Type.getTypeInternal: invalid descriptor branch not found')
}
def lenient = new InsnList()
lenient.add(new TypeInsnNode(Opcodes.NEW, 'org/objectweb/asm/Type'))
lenient.add(new InsnNode(Opcodes.DUP))
lenient.add(new IntInsnNode(Opcodes.BIPUSH, 12)) // Type.INTERNAL
lenient.add(new VarInsnNode(Opcodes.ALOAD, 0))
lenient.add(new VarInsnNode(Opcodes.ILOAD, 1))
lenient.add(new VarInsnNode(Opcodes.ILOAD, 2))
lenient.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, 'org/objectweb/asm/Type', '<init>',
'(ILjava/lang/String;II)V', false))
lenient.add(new InsnNode(Opcodes.ARETURN))
replaceThrow(method, create, lenient)
},
'org/objectweb/asm/ClassVisitor': { ClassNode node -> removeApiChecks(node) },
'org/objectweb/asm/FieldVisitor': { ClassNode node -> removeApiChecks(node) },
'org/objectweb/asm/commons/InstructionAdapter': { ClassNode node -> removeApiChecks(node) },
'org/objectweb/asm/MethodVisitor': { ClassNode node ->
def fiveArgs = node.methods.find {
it.name == 'visitMethodInsn' && it.desc == '(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V'
}
def fourArgs = node.methods.find {
it.name == 'visitMethodInsn' && it.desc == '(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V'
}
// 5 args, ASM 4 visitor: record the interface flag instead of throwing, call the 4 args
// method, then drop the flag if nothing consumed it.
def interfaceCheck = fiveArgs.instructions.find {
it.opcode == Opcodes.LDC && it.cst instanceof String && it.cst.startsWith('INVOKESPECIAL/STATIC')
}
if (interfaceCheck == null) {
throw new GradleException('ASM MethodVisitor.visitMethodInsn: interface check not found')
}
def pend = new InsnList()
pend.add(new VarInsnNode(Opcodes.ILOAD, 5))
pend.add(new MethodInsnNode(Opcodes.INVOKESTATIC, legacyApi, 'pendInterface', '(Z)V', false))
replaceThrow(fiveArgs, previousNew(interfaceCheck), pend)
def legacyCall = fiveArgs.instructions.find {
it instanceof MethodInsnNode && it.name == 'visitMethodInsn' && it.desc == fourArgs.desc
}
fiveArgs.instructions.insert(legacyCall,
new MethodInsnNode(Opcodes.INVOKESTATIC, legacyApi, 'clearInterface', '()V', false))
// 4 args: use the recorded flag rather than guessing it from the opcode.
def forward = fourArgs.instructions.find {
it instanceof MethodInsnNode && it.name == 'visitMethodInsn' && it.desc == fiveArgs.desc
}
fourArgs.instructions.insertBefore(forward,
new MethodInsnNode(Opcodes.INVOKESTATIC, legacyApi, 'interfaceFlag', '(Z)Z', false))
removeApiChecks(node)
},
]

/** The NEW of the exception thrown right after {@code insn}. */
def previousNew(AbstractInsnNode insn) {
while (insn.opcode != Opcodes.NEW) {
insn = insn.previous
}
return insn
}

/** Replaces the instructions from {@code create} to the next ATHROW. */
def replaceThrow(MethodNode method, AbstractInsnNode create, InsnList replacement) {
def insn = create
while (insn.opcode != Opcodes.ATHROW) {
def next = insn.next
method.instructions.remove(insn)
insn = next
}
method.instructions.insertBefore(insn, replacement)
method.instructions.remove(insn)
}

/** Removes every {@code throw new UnsupportedOperationException("<feature> requires ASMx")}. */
def removeApiChecks(ClassNode node) {
int removed = 0
node.methods.each { method ->
def checks = method.instructions.findAll {
it.opcode == Opcodes.LDC && it.cst instanceof String && it.cst ==~ /.* requires ASM\d+/
}
checks.each { check ->
replaceThrow(method, previousNew(check), new InsnList())
removed++
}
}
if (removed == 0) {
throw new GradleException("ASM ${node.name}: no API check found")
}
}

tasks.register('patchAsm') {
inputs.files configurations.bundledAsm
inputs.property 'patches', asmPatches.keySet().join(',')
outputs.dir patchedAsmDir
doLast {
asmPatches.each { name, patch ->
def bytes = configurations.bundledAsm.collect { jar ->
zipTree(jar).matching { include "${name}.class" }.files
}.flatten().first().bytes
def node = new ClassNode()
new ClassReader(bytes).accept(node, 0)
patch(node)
def writer = new ClassWriter(ClassWriter.COMPUTE_MAXS)
node.accept(writer)
def out = patchedAsmDir.get().file("${name}.class").asFile
out.parentFile.mkdirs()
out.bytes = writer.toByteArray()
}
}
}

jar {
archiveBaseName = 'mcpc-java8-agent'
dependsOn 'patchAsm'
from(patchedAsmDir)
archiveBaseName = 'mcpc-java17-agent'
archiveVersion = ''
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from({ configurations.bundledAsm.collect { zipTree(it) } }) {
include 'org/objectweb/asm/**'
exclude asmPatches.keySet().collect { "${it}.class" }
}
from({ configurations.legacyAsmCommons.collect { zipTree(it) } }) {
include 'org/objectweb/asm/commons/Remapping*Adapter*.class'
}
manifest {
attributes(
'Premain-Class': 'gg.nationsglory.mcpcj8.McpcJava8Agent',
'Premain-Class': 'gg.nationsglory.mcpcj17.McpcJava17Agent',
'Can-Redefine-Classes': 'false',
'Can-Retransform-Classes': 'false',
'Implementation-Title': 'MCPC+ Java 8 Agent',
'Implementation-Version': project.version
'Implementation-Title': 'MCPC+ Java 17 Agent',
'Implementation-Version': project.version,
'Bundled-ASM-Version': asmVersion
)
}
}
Empty file modified gradlew
100644 → 100755
Empty file.
2 changes: 1 addition & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
@@ -1 +1 @@
rootProject.name = 'mcpc-java8-agent'
rootProject.name = 'mcpc-java17-agent'
Loading
Loading