- Witchcraft est un projet expérimental qui implémente un serveur Minecraft à partir de zéro uniquement avec Bash. Il couvre le protocole binaire, Join Game et même l’envoi de chunks, avec pour objectif de permettre la connexion d’un vrai client.
- La limite de Bash, qui ne peut pas conserver les octets nuls dans les chaînes, est contournée en ne stockant pas le binaire dans des variables, mais en le traitant dans des pipelines
ddetxxd. - Les difficultés d’implémentation se sont concentrées sur les formats de données propres à Minecraft, comme VarInt/VarLong, les flottants IEEE 754, Position et NBT. La conversion des Double était particulièrement lente à cause du coût des appels à des commandes externes.
- Le serveur a été étendu dans l’ordre suivant : Server List Ping, handshake, Join Game, puis paquets Chunk Data And Update Light. Le Dimension codec utilise un blob binaire NBT récupéré depuis un serveur vanilla.
- Des plugins à base de hooks permettent de modifier la génération du monde et les effets, mais il reste des limites : multijoueur inachevé, communication entre threads basée sur
/dev/shm/witchcraft, échanges de données lents et dépendance à BusyBox 1.35.0.
Gérer le protocole Minecraft binaire avec Bash
- Les premières tentatives visaient le Classic protocol de 2009, mais la difficulté de parser correctement des données binaires avec Bash est apparue avant tout.
- Les chaînes Bash ignorent les octets nuls (0x00) et il n’existe aucun moyen de détecter leur présence, ce qui peut corrompre les données dans un protocole binaire strict.
- Le contournement consiste à garder les données binaires dans le pipeline, sans les placer dans des variables Bash ni dans des substitutions de commande.
dd count=$len bs=1 status=none | xxd -plit le nombre d’octets nécessaire et les convertit en chaîne hexadécimale.- La correspondance de motifs, les substitutions et l’extraction de données sont effectuées sur la chaîne hexadécimale.
- Pour envoyer une réponse, l’option reverse de
xxdreconvertit le tout en binaire.
- Pour accepter les connexions sur le port TCP par défaut de Minecraft,
ncatest utilisé ; lorsqu’une connexion arrive, le script shell principalmc.shest exécuté.
Implémentation des types de données du protocole
- Le premier paquet le plus simple à implémenter est Server List Ping.
- Ce n’est pas un paquet obligatoire : même si le serveur ne répond pas correctement, la connexion au jeu reste possible.
- Il permet toutefois de se familiariser facilement avec des concepts essentiels du protocole, comme les types de données.
-
VarInt et VarLong
- Les VarInt/VarLong de Minecraft sont une variante de LEB128, qui peut être familière aux personnes ayant déjà travaillé avec MQTT.
- LEB128 est une méthode de compression qui stocke la longueur d’un entier en divisant les octets en 1 bit de signal et 7 bits de données.
- Si le premier bit vaut 0, l’octet courant est le dernier ; s’il vaut 1, un autre octet suit.
- Comme il était difficile de transposer telle quelle l’implémentation de référence en Bash, un encodeur maison a été écrit avec modulo et division.
- Le décodeur est implémenté de façon proche de la référence, avec des opérations AND et des multiplications.
- Plus que LEB128 lui-même, ce qui s’est révélé pénible est sa présence un peu partout dans le protocole, mêlé à des int, long et signed short classiques.
-
Flottants IEEE 754
- La conversion des Double IEEE 754 a été l’une des parties les plus agaçantes de l’implémentation.
- Une implémentation de base nécessite une boucle appliquant des puissances négatives, mais Bash ne prend pas en charge nativement les puissances négatives.
- Perl a été évité, et
bcsemblait ne pas prendre en charge les puissances dans l’environnement d’utilisation ou avec la version de BusyBox concernée. awkpouvant traiter des puissances négatives comme2**-1, il a été utilisé pour implémenter la conversion.- L’implémentation initiale était si lente qu’il fallait plusieurs minutes pour traiter environ 50 à 100 paquets envoyés par le client dans les paquets Player Move, chacun contenant trois Double X, Y et Z.
- La structure appelait
awkà répétition dans une boucle Bashfor; la boucle a été déplacée à l’intérieur deawkafin de réduire le nombre d’appels à des commandes externes. - La conversion est ensuite descendue à environ 10 ms sur un Xeon E5-2680v2.
- Le chiffre d’environ 350 ms pour la version précédente n’est pas une mesure certaine.
-
Type de données Position
- Position est un type de données basé sur un Long 64 bits créé par Mojang.
- X est stocké dans les 26 bits de poids fort, Z dans les 26 bits du milieu et Y dans les 12 bits de poids faible.
- Bash dispose des opérateurs de bitshift nécessaires, ce qui a rendu l’implémentation simple.
- Ce type est toutefois peu utilisé, et beaucoup de paquets stockent les coordonnées X, Y et Z comme valeurs Double séparées.
- Résultat : les données de position passent de 64 bits à 192 bits par paquet.
- En supposant que seules les valeurs jusqu’à la world border par défaut de 30 000 000 soient nécessaires, on obtient une précision de flottant à 9 chiffres.
- Les communications serveur classiques utilisent zlib, et il est considéré qu’une précision supérieure à 2 ou 3 décimales n’est pas vraiment nécessaire pour représenter une position à l’intérieur d’un bloc.
-
NBT
- NBT est un format interne de Mojang, comparable à JSON pour les données binaires.
- Comme JSON, il est aussi utilisé pour stocker des données arbitraires hors spécification.
- Mojang stocke par exemple des bitstreams de longueur variable sous forme de tableaux de Long.
- Si le tableau n’est pas aligné sur des Long ou des octets, le dernier Long est complété par des zéros.
- Un parseur NBT avait été presque achevé, mais il a été jugé inutile d’aller jusqu’au bout.
- Ce code a été perdu lors d’un crash système, à une période où le projet utilisait beaucoup
tmpfscomme répertoire de travail.
Créer un serveur auquel on peut se connecter
- Après Server Ping, l’étape suivante consistait à gérer le handshake et les paquets supplémentaires nécessaires à une vraie connexion au jeu.
- Pour qu’un client entre sur le serveur, il doit terminer le handshake et recevoir les paquets liés aux chunks, à la position du joueur, à l’inventaire et à Join Game.
- Les plus gros obstacles étaient le paquet Join Game et les structures de données internes des paquets Chunk.
-
Join Game
- Le paquet Join Game transmet les métadonnées initiales.
- Il contient l’entity ID du joueur, le gamemode, les informations liées au monde et le Dimension codec, apparu autour de Minecraft 1.16.
- Le Dimension codec étant un NBT Compound, il représentait une charge d’implémentation importante.
- Witchcraft utilise ce champ NBT récupéré depuis un serveur vanilla.
- C’est le seul blob binaire de l’implémentation ; il serait possible de le réimplémenter, mais il a été jugé inutile de le personnaliser.
-
Chunk Data And Update Light
- Le paquet Chunk Data And Update Light paraît d’abord gros et complexe, mais il devient simple si l’on met plusieurs champs BitSet à
0x00et que l’on n’envoie pas le champ Block Entity. - Les champs restants sont X, Y, heightmaps et Data.
- Les heightmaps sont une forme plus complexe d’encodage d’une répétition de
b000000010, et il est considéré qu’en pratique elles peuvent valoir à peu près n’importe quoi.
- Le paquet Chunk Data And Update Light paraît d’abord gros et complexe, mais il devient simple si l’on met plusieurs champs BitSet à
Gestion des Chunk Sections et des palettes
- Le champ Data est un tableau de Chunk Sections.
- Une Chunk Section correspond à 16×16×16 blocs, et plusieurs sections peuvent être empilées pour former un Chunk.
- Pour simplifier l’implémentation, ce tableau n’utilise qu’un seul élément.
- Une Chunk Section se compose d’un block count, d’un block states container et d’un biome container.
- Les block states et le biome container utilisent une structure de palette.
- Avant les données de blocs proprement dites, le serveur doit définir la correspondance entre local block ID et global block ID.
- Cette structure sert à faire tenir un maximum de données dans un minimum d’espace.
- Les définitions de blocs peuvent descendre jusqu’à 4 bits.
- Pour faciliter la gestion, Witchcraft utilise 8 bits per block au lieu du minimum de 4 bits.
- Cela donne 256 entrées de palette disponibles.
- Les données réelles du chunk peuvent alors être envoyées sous forme de nombres hexadécimaux pointant vers des entrées de palette.
- Une palette 4 bits est elle aussi facile à manipuler dans une chaîne hexadécimale, puisqu’un octet peut représenter deux blocs, mais elle limite à 16 blocs par chunk.
- Le standard autorise de 4 bits per block à 9 bits per block ; toute autre valeur est considérée comme un direct palette mapping en 15 bits per block.
- La palette des biomes est traitée séparément.
- Une palette vide est envoyée, et le biome ID
0x01, c’est-à-direminecraft:plains, est directement mappé à toutes les régions du chunk. - Cela repose sur le résultat d’une rétro-ingénierie du comportement vanilla.
- La documentation existante de cette partie du paquet est soupçonnée d’être inexacte.
- Une palette vide est envoyée, et le biome ID
Structure de plugins à base de hooks et démos
- Comme l’implémentation de base n’affiche que des chunks ordinaires, il fallait une démo montrant que le serveur pouvait faire plus qu’afficher des chunks.
- Pour éviter de créer une arborescence de sources séparée pour chaque démo, des fonctions surchargeables ont été appelées hooks, et le serveur peut charger du code utilisateur.
- Cette structure permet d’aller de la modification de la forme du monde à la connexion de
pkt_effect, afin que le joueur émette un ticking noise lorsqu’il bouge la souris. - Un plugin d’exemple génère un chunk en choisissant des blocs aléatoires dans la palette par défaut.
hook_chunks()appellechunk_header.- Pour les 4096 blocs, il ajoute en hexadécimal la valeur
RANDOM%30. - Le chunk généré est enregistré dans
$TEMP/world/0000000000000000. - Plusieurs appels à
pkt_chunksur les coordonnées alentour envoient les chunks.
- Une autre démo, digmeout, est un petit jeu basé sur le score.
- Le joueur est placé dans un chunk contenant des pierres et minerais disposés aléatoirement.
- Le but est de miner les minerais les plus précieux avant la fin du temps imparti.
Limites de Witchcraft
- Bash gère très mal les nombres décimaux.
- Les entiers sont assez bien pris en charge.
- Les décimaux doivent être traités en multipliant à l’entrée, puis en plaçant le point au bon endroit à la sortie.
- De ce fait, la plupart, voire la totalité, des nombres traités par Witchcraft sont des int.
- Le multijoueur ne fonctionne pas correctement.
- Il fonctionne dans une certaine mesure, mais aucun temps n’a été consacré à le terminer ou à le peaufiner.
- Witchcraft est techniquement un serveur multi-threaded.
- Cela impose un hack peu élégant pour la communication entre threads.
- La plupart des données globales sont stockées sous
/dev/shm/witchcraftet référencées en interne via$TEMP.
- Les performances restent une forte contrainte.
- Les échanges de données entre plusieurs threads sont particulièrement lents.
- Il est difficile d’envoyer de grandes quantités de données, et générer puis envoyer 16 chunks solides peut prendre jusqu’à 1 seconde.
- À l’heure actuelle, il ne s’exécute que si la version récente BusyBox 1.35.0 est installée.
- Il n’a pas été testé avec GNU coreutils et il est supposé ne pas fonctionner avec.
1 commentaires
Avis sur Hacker News
Pour avoir beaucoup travaillé sur des serveurs Minecraft scriptables pour Java et Bedrock, c’est plutôt impressionnant.
Point bonus pour avoir utilisé « duckduckgoing » dans une phrase.
Discussion de l’époque : https://news.ycombinator.com/item?id=30347501 — 92 commentaires
Les exposants négatifs, c’est simplement 2^(-n) = 1/(2^n).
J’ai été surpris que l’auteur, après avoir même donné 2^-1 = 0,5 comme exemple, n’y ait pas pensé et ait fini par déléguer ça à awk.
Il aurait dû utiliser ma bibliothèque idiote (?) ctypes.sh : https://github.com/taviso/ctypes.sh
Ça donne accès depuis Bash à libm, poll(), select(), etc. :)
On dirait qu’on ressuscite Frankenstein.
Si Bash nécessite en plus du code C compilé installé séparément, alors autant exiger directement du C ou du Python, et la justification pour dire que c’est « écrit en Bash » devient moins forte. Je dis ça avec respect, bien sûr, et j’aime ce genre de projets « inutiles ».
Cela dit, ce projet Minecraft dépend aussi d’outils externes comme xxd, donc dans ce cas utiliser ctypes.sh ne serait pas pire, et l’argument se tient même plutôt bien.
À noter qu’on peut lire, stocker, manipuler, calculer et afficher du binaire en pur Bash sans dd ni xxd. Le seul octet problématique est le nul : on ne peut pas le stocker directement, mais on peut détecter qu’il a été lu, l’enregistrer, puis le restaurer à la sortie, ou l’utiliser comme valeur numérique pour des indices de tableau, des offsets d’octets, etc.
Un exemple minimal de copie de fichier binaire est
while LANG=C IFS= read -d '' -r -n 1 x ;do printf '%c' "$x" ;done bin2. Cela dit, c’est juste faire cat sans cat, donc l’utilité n’apparaît pas vraiment.Un exemple plus général et utile se trouve ici : https://gist.github.com/bkw777/c1413d0e3de6c54524ddae890fe8d705
En combinant
LANG=C,IFS=et-d'', on peut accéder à tous les octets sauf 0x00, et distinguer, via la valeur de retour deread(), « a lu 0x00 », « n’a rien lu » et « fin de l’entrée ».Il n’est pas non plus nécessaire d’utiliser
<et>sur toute la commandewhile(). On peut ouvrir avecexec 3<>file_or_fifo_or_tty, puis utiliserread -u3,printf >&3, etc. dans la boucle.Un exemple de lecture depuis un port série est ici : https://gist.github.com/bkw777/ddde771cc85fdd888c7ec74953193d66, et le code que j’ai réellement utilisé est ici : https://github.com/bkw777/pdd.sh. Regardez
tpdd_read,tpdd_write,file_to_fhex,str_to_shex: ils lisent et écrivent sur un port série et des fichiers locaux, tout en traitant les données de plusieurs façons.Ces boucles ne sont même pas des sous-shells, encore moins des processus externes. Si l’on manipule des variables dans la boucle, on reste dans le contexte d’origine. Comme il n’y a pas d’enfant, il est même difficile de parler de shell parent.
Les exemples lisent un octet à la fois avec
read -n 1, mais ce n’est pas obligatoire. Si l’on lit sans-n1, chaque itération de boucle récupère autant de données que possible entre deux nuls, ce qui réduit la mémoire et le nombre d’itérations nécessaires.Dans mon cas, je dois découper et manipuler des octets individuels et des plages d’octets à partir d’offsets numériques, et les données sont petites selon les standards actuels, donc un tableau de paires hexadécimales est très pratique.
a[n] == byte n, on peut traiter de la même façon les caractères affichables, non affichables et nuls, et on peut non seulement les enregistrer/recréer avecprintf, mais aussi les utiliser directement comme valeurs numériques, par exemple0x$nou0x${a[n]}.Par exemple, on peut lire le deuxième octet de
h[]comme longueur de la charge utile qui suit, puis extraire cette charge utile avec${h[@]:2:0x${h[1]}}.Voilà ce qu’on attend d’un vrai site de hackers. Excellent.
Je ne suis pas très bon en programmation Bash, mais je suis surpris de voir à quel point c’est en réalité puissant et moins horrible qu’on ne le pense.
Bash fait partie de mes outils préférés, au même titre que vim et Lua.
C’est un binaire unique de moins de 2 Mo, il est partout, et il peut faire beaucoup plus de choses que beaucoup ne l’imaginent.
Avec shellcheck et de bonnes pratiques, Bash peut devenir lisible et sûr.
Si l’on a besoin de plus, on peut greffer des utilitaires en C/C++ sans FFI compliquée, et sans devoir tirer tout un tas de dépendances obscures via pip/npm.
Il y a souvent des scripts Bash inline dans la CI et les Dockerfile, et j’aime aussi tout déplacer dans des fichiers de scripts séparés. Maintenant que shellcheck est dans la CI, ce code est lui aussi vérifié.
Les développeurs n’aiment pas ça en disant que « ça nuit à la lisibilité », alors qu’en plus de ne pas la dégrader, c’est beaucoup plus sûr.
Le problème suivant, c’est que les mêmes scripts Bash se retrouvent dupliqués dans les mêmes fichiers de plusieurs dépôts. C’est une architecture de build distribuée ; je suis ouvert aux idées d’amélioration.
Je regrette de ne pas y avoir consacré plus de temps quand j’étais étudiant. C’est l’un des rares outils qui m’a suivi pendant toute ma carrière.
Je me demande s’il se fait encore des serveurs custom pour des jeux commerciaux.
La version Java, en particulier, occupe une position assez singulière : elle continue de recevoir de grosses mises à jour de contenu gratuites, tout en prenant officiellement en charge l’exécution de toutes les anciennes versions, et la communauté de modding est extrêmement active.
Minecraft (Java) est à la fois un jeu et presque un moteur de jeu sur lequel d’autres construisent.
On s’y met pour corriger des bugs, apprendre un langage ou voir comment ça fonctionne sous le capot.
Minecraft est l’un des meilleurs jeux sortis depuis Quake.
Que ce genre de chose se soit réellement produit, bon sang.
J’ai lu beaucoup d’articles sur des implémentations, mais celui-ci est écrasant. C’est de très loin l’un des meilleurs récits de projets bizarres que j’aie lus, et c’est vraiment un excellent article.