- Witchcraft es un proyecto experimental que implementa un servidor de Minecraft desde cero usando solo Bash, abordando el protocolo binario, Join Game y hasta el envío de chunks con el objetivo de permitir la conexión de clientes reales
- La limitación de Bash de no poder conservar bytes nulos en cadenas se resolvió evitando guardar binarios en variables y procesándolos dentro de tuberías con
ddyxxd - Las principales dificultades de implementación se concentraron en formatos de datos propios de Minecraft como VarInt/VarLong, punto flotante IEEE 754, Position y NBT; en especial, la conversión de Double era lenta por el costo de invocar comandos externos
- El servidor se fue ampliando en este orden: Server List Ping, handshake, Join Game y paquetes Chunk Data And Update Light; para el dimension codec se usó un blob binario NBT tomado de un servidor vanilla
- Puede cambiar la generación del mundo y los efectos mediante plugins basados en hooks, pero siguen pendientes el multijugador incompleto, la comunicación entre hilos basada en
/dev/shm/witchcraft, el intercambio de datos lento y la dependencia de BusyBox 1.35.0
Manejo del protocolo binario de Minecraft en Bash
- El intento inicial apuntaba al Classic protocol de 2009, pero primero quedó en evidencia la limitación de que Bash no puede parsear bien datos binarios
- Las cadenas de Bash ignoran los bytes nulos (0x00), y tampoco hay forma de detectar si apareció uno, por lo que en un protocolo binario estricto los datos pueden corromperse
- La solución fue mantener los datos binarios dentro de tuberías, sin meterlos en variables de Bash ni en sustituciones de comandos
dd count=$len bs=1 status=none | xxd -plee la cantidad de bytes necesaria y la convierte en una cadena hex- Sobre esa cadena hex se hacen pattern matching, sustituciones y extracción de datos
- Para enviar respuestas, se convierte otra vez a binario con la opción reverse de
xxd
- Para aceptar conexiones en el puerto TCP por defecto de Minecraft se usa
ncat, y cuando entra una conexión se ejecuta el script principalmc.sh
Implementación de tipos de datos del protocolo
- El primer objetivo fácil de implementar fue el paquete Server List Ping
- No es un paquete obligatorio, así que aunque el servidor no responda bien, igual es posible conectarse al juego
- Aun así, sirve para familiarizarse con conceptos centrales del protocolo como los data types
-
VarInt y VarLong
- Los VarInt/VarLong de Minecraft son una variante de LEB128 que puede resultar familiar para quienes tienen experiencia con MQTT
- LEB128 es un formato comprimido que guarda la longitud de enteros dividiendo cada byte en 1 bit de señal y 7 bits de datos
- Si el primer bit es 0, ese byte es el último; si es 1, continúa otro byte
- Como era difícil trasladar tal cual la reference implementation a Bash, se escribió un codificador propio usando módulo y división
- El decodificador se implementó de forma similar a la referencia usando operaciones AND y multiplicación
- Más que LEB128 en sí, lo molesto era que aparecía por todo el protocolo mezclado con int, long y signed short normales
-
Punto flotante IEEE 754
- La conversión de IEEE 754 Double fue una de las partes más fastidiosas de implementar
- La implementación básica necesitaba un bucle que aplicara potencias negativas, pero Bash no las soporta de forma nativa
- Se evitó usar Perl, y
bcaparentemente no soportaba exponenciación en ese entorno o en esa versión de BusyBox awksí puede manejar potencias negativas como2**-1, así que se usó para implementar la conversión- La implementación inicial era tan lenta que procesar unos 50 a 100 paquetes enviados por el cliente en Player Move, con 3 valores Double de X·Y·Z por paquete, podía tomar varios minutos
- La estructura que llamaba repetidamente a
awkdentro de un bucleforde Bash se pasó a un bucle interno enawkpara reducir la cantidad de invocaciones externas - Después, la conversión bajó a unos 10 ms en un Xeon E5-2680v2
- La cifra anterior de unos 350 ms no era una medición totalmente confiable
-
Tipo de dato Position
- Position es un tipo de dato basado en Long de 64 bits creado por Mojang
- X se guarda en los 26 bits superiores, Z en los 26 bits intermedios y Y en los 12 bits inferiores
- Bash sí tiene los operadores de desplazamiento de bits necesarios, así que implementarlo fue fácil
- Aun así, este tipo no se usa mucho, y muchos paquetes guardan las coordenadas X·Y·Z como valores Double separados
- Como resultado, los datos de posición crecen de 64 bits a 192 bits por paquete
- Si se asume que solo hacen falta números hasta el world border por defecto de 30,000,000, se obtiene precisión de punto flotante de 9 dígitos
- La comunicación normal del servidor usa zlib, y para representar posiciones dentro de bloques en la práctica no parece necesario más de 2 o 3 decimales
-
NBT
- NBT es un formato interno de Mojang, parecido a JSON pero para datos binarios
- Igual que JSON, también se usa para guardar datos arbitrarios fuera de la especificación
- Mojang, por ejemplo, guarda bitstreams de longitud variable como arreglos de Long
- Si el arreglo no está alineado a Long o a byte, el último Long se rellena con 0
- El parser de NBT estuvo cerca de completarse, pero se decidió que no valía la pena terminarlo
- Ese código se perdió en un crash del sistema cuando el directorio del proyecto usaba mucho
tmpfs
Crear un servidor al que sí se pueda entrar
- El siguiente paso después de Server Ping fue manejar el handshake real y más paquetes necesarios para entrar al juego
- Para que el cliente pueda entrar al servidor, debe completar el handshake y recibir paquetes relacionados con chunks, player position, inventory y join game
- El mayor obstáculo fueron las estructuras de datos internas del paquete Join Game y de los paquetes de Chunk
-
Join Game
- El paquete Join Game envía metadatos iniciales
- Incluye el entity ID del jugador, gamemode, información del mundo y el Dimension codec añadido alrededor de Minecraft 1.16
- El Dimension codec es un NBT Compound, así que implicaba bastante carga de implementación
- Witchcraft reutiliza este campo NBT tomándolo de un servidor vanilla
- Esta es la única parte con un blob binario en toda la implementación, y aunque se podría reimplementar, se consideró que no hacía falta personalizarla
-
Chunk Data And Update Light
- El paquete Chunk Data And Update Light al principio parecía grande y complejo, pero se simplifica si varios campos BitSet se dejan en
0x00y no se envía el campo Block Entity - Los campos que quedan son X, Y, heightmaps y Data
- Los heightmaps son una forma más compleja de codificar la repetición de
b000000010, y en la práctica se consideró que podían ser casi cualquier valor
- El paquete Chunk Data And Update Light al principio parecía grande y complejo, pero se simplifica si varios campos BitSet se dejan en
Manejo de Chunk Section y palette
- El campo Data es un arreglo de Chunk Section
- Una Chunk Section es un bloque de 16×16×16, y apilando varias sections se puede formar un Chunk completo
- Para simplificar la implementación, este arreglo usa un solo elemento
- Chunk Section se compone de block count, block states container y biome container
- Tanto block states como biome container usan una estructura de palette
- Antes de los datos reales de bloques, el servidor debe definir el mapeo entre local block ID y global block ID
- Es una estructura pensada para meter la mayor cantidad posible de datos en el menor espacio
- La definición de bloques puede reducirse hasta un mínimo de 4 bits
- Witchcraft usa 8 bits por bloque en lugar del mínimo de 4 bits para facilitar la gestión
- Así se obtienen 256 entradas disponibles en la palette
- Los datos reales del chunk pueden enviarse simplemente como números hex que apuntan a entradas de la palette
- Una palette de 4 bits también sería fácil de manejar en una cadena hex, porque un byte representa dos bloques, pero limitaría el chunk a 16 bloques
- El estándar permite desde 4 bits por bloque hasta 9 bits por bloque; fuera de eso se considera un direct palette mapping de 15 bits por bloque
- La biome palette se maneja de otra manera
- Se envía una palette vacía y se mapea directamente el biome ID
0x01, es decir,minecraft:plains, a todas las regiones dentro del chunk - Esto se basa en ingeniería inversa del comportamiento vanilla
- Se sospecha que la documentación existente de esa parte del paquete podría ser inexacta
- Se envía una palette vacía y se mapea directamente el biome ID
Estructura de plugins basada en hooks y demos
- Con la implementación básica solo se mostraban chunks comunes, así que hacía falta una demo para mostrar que el servidor podía hacer algo más que desplegar chunks
- Para evitar crear un árbol de código separado para cada demo, se llamaron hooks a las funciones sobreescribibles y se permitió que el servidor cargara código de usuario
- Con esta estructura se puede desde cambiar la forma del mundo hasta conectar
pkt_effectpara que el jugador haga un ticking noise al mover el mouse - Un plugin de ejemplo genera chunks eligiendo bloques aleatorios de la palette base
- En
hook_chunks()se llama achunk_header - Para 4096 bloques se agrega en hex el valor
RANDOM%30 - El chunk generado se guarda en
$TEMP/world/0000000000000000 - Luego se llama varias veces a
pkt_chunksobre coordenadas cercanas para enviar los chunks
- En
- Otra demo, digmeout, es un juego simple basado en puntaje
- Lanza al jugador a un chunk con piedra y minerales colocados al azar
- La idea es minar el mineral más valioso antes de que se acabe el tiempo
Limitaciones de Witchcraft
- Bash es muy malo para manejar fracciones decimales
- Los enteros se pueden manejar más o menos bien
- Las fracciones decimales deben tratarse multiplicando en la entrada y colocando el punto en la posición adecuada al salir
- Por eso, la mayoría o todos los números que maneja Witchcraft son int
- El multijugador no funciona bien
- Hasta cierto punto sí funciona, pero no se invirtió tiempo en terminarlo ni pulirlo
- Witchcraft es técnicamente un servidor multihilo
- Como resultado, necesita hacks poco elegantes para la comunicación entre hilos
- La mayor parte de los datos globales se guarda bajo
/dev/shm/witchcrafty se referencia internamente como$TEMP
- El rendimiento sigue siendo una gran limitación
- Sobre todo, el intercambio de datos entre varios hilos es lento
- Es difícil enviar grandes cantidades de datos, y generar y mandar 16 chunks sólidos puede tardar hasta 1 segundo
- Actualmente solo funciona si está instalado BusyBox 1.35.0 reciente
- No se probó con GNU coreutils y se espera que no funcione
1 comentarios
Opiniones en Hacker News
Como alguien que ha trabajado bastante en servidores de Minecraft programables para Java y Bedrock, esto es bastante impresionante.
También suma puntos por usar “duckduckgoing” en una oración.
Discusión de aquel entonces: https://news.ycombinator.com/item?id=30347501 — 92 comentarios
Un exponente negativo no es más que 2^(-n) = 1/(2^n).
Me sorprendió que el autor, incluso habiendo puesto como ejemplo 2^-1 = 0.5, no se diera cuenta de eso y terminara pasándoselo a awk.
Debería haber usado mi biblioteca tonta(?) ctypes.sh: https://github.com/taviso/ctypes.sh
Con eso se puede acceder desde Bash a cosas como libm, poll(), select() :)
Se siente como revivir a Frankenstein.
Si además necesitas código C compilado instalado aparte para Bash, entonces también podrías pedir C o Python directamente, y se debilita la razón para llamarlo “escrito en Bash”. Lo digo con respeto, claro, y me encantan estos proyectos “inútiles”.
Dicho eso, este proyecto de Minecraft también depende de herramientas externas como xxd, así que en este caso usar ctypes.sh no lo haría peor; de hecho, tiene sentido.
Como referencia, incluso sin dd ni xxd se puede leer, almacenar, manipular, calcular e imprimir binarios en Bash puro. El único byte problemático es el nulo: aunque no se pueda almacenar directamente, se puede detectar que fue leído, registrarlo y luego restaurarlo al imprimir, o usarlo como valor numérico para índices de arreglos, offsets de bytes, etc.
Un ejemplo mínimo para copiar un archivo binario es
while LANG=C IFS= read -d '' -r -n 1 x ;do printf '%c' "$x" ;done bin2. Pero eso es básicamente hacer cat sin cat, así que no muestra muy bien su utilidad.Hay un ejemplo más general y útil en https://gist.github.com/bkw777/c1413d0e3de6c54524ddae890fe8d705
Combinando
LANG=C,IFS=y-d''se puede acceder a todos los bytes excepto 0x00, y con el valor de retorno deread()se puede distinguir entre “leí 0x00”, “no pude leer nada” y “se terminó la entrada”.Tampoco hace falta usar
<y>en todo el comandowhile(). Se puede abrir conexec 3<>file_or_fifo_or_ttyy usarread -u3,printf >&3, etc. dentro del bucle.Un ejemplo de lectura desde un puerto serial está en https://gist.github.com/bkw777/ddde771cc85fdd888c7ec74953193d66, y el código que usé realmente está en https://github.com/bkw777/pdd.sh. Si miras
tpdd_read,tpdd_write,file_to_fhex,str_to_shex, verás cómo lee y escribe puertos seriales y archivos locales, procesando los datos de varias maneras.Esos bucles ni siquiera son procesos externos, ni subshells. Si manipulas variables dentro del bucle, sigues en el mismo contexto original. Como no hay hijo, incluso es raro llamarlo shell padre.
Los ejemplos leen byte por byte con
read -n 1, pero no tiene por qué ser así. Si lees sin-n1, cada iteración del bucle reúne la mayor cantidad posible de datos entre nulos, usando menos memoria y menos iteraciones.En mi caso de uso necesito cortar y manipular bytes individuales y rangos de bytes por offsets numéricos, y los datos además son pequeños para los estándares actuales, así que un arreglo de pares hexadecimales resulta muy cómodo.
a[n] == byte n, y puedo tratar de la misma forma caracteres imprimibles, no imprimibles y nulos; además de guardarlos y regenerarlos conprintf, también puedo usarlos directamente como valores numéricos con0x$no0x${a[n]}.Por ejemplo, puedo leer el segundo byte de
h[]como longitud del payload que le sigue y extraer ese payload con${h[@]:2:0x${h[1]}}.Así sí se ve un verdadero sitio de hackers. Excelente.
No soy muy bueno programando en Bash, pero me sorprende que en la práctica sea más potente y menos horrible de lo que uno pensaría.
Bash es una de mis herramientas favoritas, junto con vim y Lua.
Es un binario único de menos de 2 MB, está en todas partes y puede hacer muchísimo más de lo que mucha gente cree.
Con shellcheck y buenas prácticas, Bash también puede volverse legible y seguro.
Si necesitas algo más, puedes agregar utilidades en C/C++ sin FFI complicadas, y no hace falta arrastrar un montón de dependencias misteriosas con cosas como pip/npm.
Muchas veces hay scripts Bash inline dentro de CI y Dockerfile, y también me gusta moverlos todos a archivos de script separados. Ahora que hay shellcheck en CI, también se revisa ese código.
Los desarrolladores lo odian diciendo que “reduce la legibilidad”, pero lejos de hacerla peor, lo vuelve mucho más seguro.
El siguiente problema es que el mismo script Bash termina duplicado en el mismo archivo de varios repositorios. Es una estructura de build distribuida; estoy abierto a ideas para mejorarla.
Me arrepiento de no haberle dedicado más tiempo cuando era estudiante, porque ha sido una de las pocas herramientas que me acompañó durante toda mi carrera.
Me pregunto si todavía se hacen servidores personalizados para juegos comerciales.
En particular, la versión Java ocupa una posición relativamente particular: sigue recibiendo grandes actualizaciones gratuitas de contenido, soporta oficialmente ejecutar todas las versiones anteriores, y la comunidad de modding es enormemente activa.
Minecraft (Java) es tanto un juego como algo cercano a un motor de juegos sobre el que otras personas construyen.
Lo hacen para corregir bugs, aprender un lenguaje o ver cómo funcionan las cosas por dentro.
Minecraft es uno de los mejores juegos que salieron después de Quake.
No puedo creer que esto haya pasado de verdad.
He leído muchos artículos sobre implementaciones, pero esto es abrumador. Es, por mucho, uno de los mejores textos sobre proyectos extraños que he leído, y está realmente excelente.