- Witchcraft é um projeto experimental que implementa um servidor de Minecraft do zero usando apenas Bash, lidando com o protocolo binário, Join Game e até envio de chunks, com o objetivo de permitir a conexão de clientes reais
- A limitação do Bash de não preservar bytes nulos em strings foi contornada processando os dados binários dentro de pipes com
ddexxd, sem colocá-los em variáveis - As principais dificuldades de implementação se concentraram em formatos de dados próprios do Minecraft, como VarInt/VarLong, ponto flutuante IEEE 754, Position e NBT; em especial, a conversão de Double era lenta por causa do custo de chamar comandos externos
- O servidor foi expandido na sequência de pacotes Server List Ping, handshake, Join Game e Chunk Data And Update Light, e o Dimension codec usa um blob binário NBT obtido de um servidor vanilla
- Plugins baseados em hooks permitem mudar a geração do mundo e os efeitos, mas ainda restam limitações como multiplayer incompleto, comunicação entre threads baseada em
/dev/shm/witchcraft, troca lenta de dados e dependência do BusyBox 1.35.0
Lidando com o protocolo binário do Minecraft no Bash
- A tentativa inicial mirava o Classic protocol de 2009, mas logo ficou claro que havia limitações para fazer parsing correto de dados binários em Bash
- Strings em Bash ignoram bytes nulos (0x00), e não há como detectar se eles ocorreram, então os dados podem ser corrompidos em um protocolo binário rígido
- A solução alternativa foi manter os dados binários dentro de pipes, sem colocá-los em variáveis do Bash nem em substituição de comandos
dd count=$len bs=1 status=none | xxd -plê a quantidade necessária de bytes e converte em string hexadecimal- Sobre essa string hexadecimal, são feitos pattern matching, substituição e extração de dados
- Para enviar a resposta, usa-se a opção reverse do
xxdpara voltar ao formato binário
- Para aceitar conexões na porta TCP padrão do Minecraft, é usado
ncat, que executa o script principalmc.shquando uma conexão chega
Implementação dos tipos de dados do protocolo
- O primeiro alvo mais viável de implementar foi o pacote Server List Ping
- Ele não é obrigatório, então mesmo sem uma resposta correta o jogo ainda pode se conectar ao servidor
- Ainda assim, é uma boa forma de aprender conceitos centrais do protocolo, como os data types
-
VarInt e VarLong
- Os VarInt/VarLong do Minecraft são uma variação de LEB128, familiar para quem já mexeu com MQTT
- LEB128 é um formato comprimido que armazena inteiros dividindo cada byte em 1 bit de sinalização e 7 bits de dados
- Se o primeiro bit for 0, aquele byte é o último; se for 1, há outro byte em seguida
- Como era difícil portar a implementação de referência diretamente para Bash, foi criado um codificador próprio usando módulo e divisão
- O decodificador foi implementado de forma semelhante à referência, usando operação AND e multiplicação
- Mais incômodo do que o próprio LEB128 era o fato de ele aparecer por todo o protocolo misturado com int, long e signed short comuns
-
Ponto flutuante IEEE 754
- A conversão de IEEE 754 Double foi uma das partes mais irritantes da implementação
- A implementação básica exige um loop com potências negativas, mas o Bash não oferece suporte nativo a isso
- Perl foi evitado, e o
bcaparentemente não suportava exponenciação no ambiente usado ou naquela versão do BusyBox - O
awkconseguia lidar com potências negativas como2**-1, então foi usado na implementação da conversão - A implementação inicial era tão lenta que levava vários minutos para processar cerca de 50 a 100 pacotes enviados pelo cliente no pacote Player Move, com 3 Doubles X·Y·Z em cada um
- A estrutura que chamava
awkrepetidamente dentro de um loopfordo Bash foi movida para um loop interno do próprioawk, reduzindo a quantidade de chamadas externas - Depois disso, a conversão caiu para cerca de 10 ms em um Xeon E5-2680v2
- O valor anterior de cerca de 350 ms não era uma medição muito confiável
-
Tipo de dado Position
- Position é um tipo de dado criado pela Mojang com base em um Long de 64 bits
- X fica nos 26 bits superiores, Z nos 26 bits do meio e Y nos 12 bits inferiores
- Como o Bash tem os operadores de bitshift necessários, a implementação foi simples
- Ainda assim, esse tipo não é muito usado, e muitos pacotes armazenam as coordenadas X·Y·Z como valores Double separados
- Com isso, os dados de posição crescem de 64 bits para 192 bits por pacote
- Assumindo que só seja necessário chegar até o world border padrão de 30.000.000, isso dá precisão de ponto flutuante de 9 dígitos
- A comunicação normal do servidor usa zlib, e para representar posição dentro de blocos provavelmente não são necessários mais que 2 ou 3 dígitos decimais na prática
-
NBT
- NBT é um formato interno da Mojang, algo parecido com JSON para dados binários
- Assim como JSON, também é usado para armazenar dados arbitrários fora da especificação
- A Mojang, por exemplo, armazena bitstreams de tamanho variável em arrays de Long
- Se o array não estiver alinhado a Long ou byte, o último Long é preenchido com 0
- O parser de NBT quase chegou a ficar pronto, mas no fim foi considerado sem valor suficiente para justificar o esforço
- Esse código foi perdido em um crash do sistema enquanto o projeto usava bastante
tmpfscomo diretório de trabalho
Criando um servidor ao qual dá para se conectar
- Depois do Server Ping, o próximo passo foi tratar o handshake e os pacotes adicionais necessários para uma conexão real ao jogo
- Para o cliente entrar no servidor, ele precisa concluir o handshake e receber pacotes de chunk, posição do jogador, inventário e Join Game
- O maior obstáculo estava nas estruturas de dados internas do pacote Join Game e dos pacotes de chunk
-
Join Game
- O pacote Join Game envia os metadados iniciais
- Entre os itens incluídos estão o entity ID do jogador, gamemode, informações do mundo e o Dimension codec, presente a partir de versões em torno do Minecraft 1.16
- Como o Dimension codec é um NBT Compound, o custo de implementação era alto
- O Witchcraft reutiliza esse campo NBT obtido de um servidor vanilla
- Essa é a única parte em blob binário da implementação; seria possível reimplementar, mas não foi considerado necessário personalizá-la
-
Chunk Data And Update Light
- O pacote Chunk Data And Update Light parece grande e complexo à primeira vista, mas fica simples se vários campos BitSet forem deixados como
0x00e o campo Block Entity não for enviado - Os campos que sobram são X, Y, heightmaps e o campo Data
- Os heightmaps são uma forma mais complexamente codificada de repetir
b000000010, e na prática parece que quase qualquer valor serve
- O pacote Chunk Data And Update Light parece grande e complexo à primeira vista, mas fica simples se vários campos BitSet forem deixados como
Tratamento de Chunk Section e palette
- O campo Data é um array de Chunk Section
- Uma Chunk Section tem blocos 16×16×16, e várias sections podem ser empilhadas para formar um Chunk
- Para simplificar a implementação, esse array usa apenas um único elemento
- Uma Chunk Section é composta de block count, block states container e biome container
- Os containers de block states e de biome usam uma estrutura de palette
- Antes dos dados reais dos blocos, o servidor precisa definir o mapeamento entre IDs locais de bloco e IDs globais de bloco
- É uma estrutura pensada para colocar o máximo possível de dados no menor espaço possível
- A definição de bloco pode chegar ao mínimo de 4 bits
- O Witchcraft usa 8 bits por bloco em vez do mínimo de 4 bits, por facilidade de gerenciamento
- Isso permite 256 entradas possíveis na palette
- Os dados reais do chunk podem então simplesmente enviar números hexadecimais apontando para entradas da palette
- Uma palette de 4 bits também seria fácil de manipular com strings hexadecimais, já que um byte representa dois blocos, mas limitaria o chunk a 16 blocos
- O padrão permite de 4 bits por bloco até 9 bits por bloco; fora disso, considera-se um mapeamento direto de palette com 15 bits por bloco
- A palette de biome é tratada de forma separada
- Envia-se uma palette vazia e faz-se o mapeamento direto do biome ID
0x01, isto é,minecraft:plains, para todas as regiões do chunk - Isso foi baseado em engenharia reversa do comportamento do vanilla
- Há suspeita de que a documentação existente dessa parte do pacote possa estar incorreta
- Envia-se uma palette vazia e faz-se o mapeamento direto do biome ID
Estrutura de plugins baseada em hooks e demos
- Como a implementação básica só mostrava chunks comuns, era preciso uma demo que mostrasse que o servidor podia fazer mais do que apenas exibir chunks
- Para não criar uma árvore de código separada para cada demo, funções sobreponíveis foram chamadas de hooks, permitindo que o servidor carregasse código do usuário
- Com essa estrutura, foi possível ir de mudanças no formato do mundo até ligar
pkt_effectpara fazer o jogador emitir ticking noise ao mover o mouse - Um plugin de exemplo gera chunks escolhendo blocos aleatórios da palette padrão
- Em
hook_chunks(), chama-sechunk_header - Para os 4096 blocos, acrescenta-se um valor hexadecimal de
RANDOM%30 - O chunk gerado é salvo em
$TEMP/world/0000000000000000 - Em seguida,
pkt_chunké chamado várias vezes para enviar chunks nas coordenadas ao redor
- Em
- Outra demo, digmeout, é um jogo simples baseado em pontuação
- O jogador é lançado em um chunk com pedra e minérios distribuídos aleatoriamente
- O objetivo é minerar os minérios mais valiosos até o fim do tempo limite
Limitações do Witchcraft
- O Bash é muito fraco para lidar com frações decimais
- Inteiros até podem ser tratados razoavelmente bem
- Frações decimais precisam ser manipuladas multiplicando na entrada e inserindo o ponto na posição apropriada na saída
- Por causa disso, a maioria ou talvez todos os números tratados pelo Witchcraft são int
- O multiplayer não funciona direito
- Até funciona em algum nível, mas não foi investido tempo para concluir ou polir isso
- O Witchcraft é tecnicamente um servidor multithread
- Como resultado, foi necessário um hack ruim para comunicação entre threads
- A maior parte dos dados globais fica armazenada em
/dev/shm/witchcraft, referenciado internamente como$TEMP
- O desempenho continua sendo uma limitação importante
- Principalmente porque a troca de dados entre várias threads é lenta
- É difícil enviar grandes volumes de dados, e gerar e enviar 16 chunks sólidos pode levar até 1 segundo
- Atualmente, ele só roda se o BusyBox 1.35.0 mais recente estiver instalado
- Não foi testado com GNU coreutils, e a expectativa é de que não funcione
1 comentários
Opiniões no Hacker News
Como alguém que já trabalhou bastante com servidores Minecraft programáveis para Java e Bedrock, isso é bem impressionante
Ganha pontos extras por ter usado “duckduckgoing” numa frase
Discussão da época: https://news.ycombinator.com/item?id=30347501 — 92 comentários
Expoente negativo é só 2^(-n) = 1/(2^n)
Achei curioso o autor não ter lembrado disso, mesmo tendo dado 2^-1 = 0.5 como exemplo, e no fim ter passado para o awk
Ele deveria ter usado minha biblioteca idiota(?) ctypes.sh: https://github.com/taviso/ctypes.sh
Aí dá para acessar libm, poll(), select() e coisas do tipo a partir do Bash :)
Dá a sensação de ressuscitar Frankenstein
Se exige até código C compilado instalado à parte para o Bash, então também daria para exigir simplesmente C ou Python, e fica mais fraco chamar isso de “escrito em Bash”. Digo isso com todo respeito, e eu gosto desse tipo de projeto “inútil”
Dito isso, esse projeto de Minecraft também depende de ferramentas externas como xxd, então neste caso usar ctypes.sh não pioraria nada; na verdade, faz sentido
A propósito, dá para ler, armazenar, manipular, calcular e emitir binário em Bash puro, mesmo sem dd ou xxd. O único byte problemático é o nulo; não dá para armazená-lo diretamente, mas dá para detectar e registrar que ele foi lido e depois restaurá-lo na saída, ou usá-lo como valor numérico em índices de array, offsets de bytes etc.
Um exemplo mínimo para copiar um arquivo binário é
while LANG=C IFS= read -d '' -r -n 1 x ;do printf '%c' "$x" ;done bin2. Mas isso é basicamente fazer cat sem cat, então a utilidade não fica muito evidenteHá um exemplo mais geral e útil em https://gist.github.com/bkw777/c1413d0e3de6c54524ddae890fe8d705
Combinando
LANG=C,IFS=e-d'', dá para acessar todos os bytes exceto 0x00, e pelo valor de retorno deread()dá para distinguir “li 0x00”, “não li nada” e “a entrada acabou”Também não é preciso usar
<e>no comandowhile()inteiro. Você pode abrir comexec 3<>file_or_fifo_or_ttye, dentro do loop, usarread -u3,printf >&3etc.Um exemplo de leitura de porta serial está em https://gist.github.com/bkw777/ddde771cc85fdd888c7ec74953193d66, e o código que usei de fato está em https://github.com/bkw777/pdd.sh. Veja
tpdd_read,tpdd_write,file_to_fhex,str_to_shex: eles leem e escrevem portas seriais e arquivos locais, processando os dados de várias formasEsses loops nem sequer são subprocessos, muito menos processos externos. Se você manipular variáveis dentro do loop, continua no mesmo contexto original. Como não há processo filho, fica até estranho chamar de shell pai
Os exemplos leem um byte por vez com
read -n 1, mas não precisa ser assim. Se você ler sem-n1, cada iteração do loop coleta o máximo de dados possível entre nulos, usando menos memória e menos iteraçõesNo meu uso, preciso recortar e manipular bytes individuais e intervalos de bytes por offset numérico, e os dados são pequenos para os padrões atuais, então um array de pares hexadecimais é muito conveniente.
a[n] == byte n, e dá para tratar caracteres imprimíveis, não imprimíveis e nulos da mesma forma; além de salvar e recriar comprintf, também dá para usar diretamente como valor numérico, como0x$nou0x${a[n]}Por exemplo, dá para ler o segundo byte de
h[]como o tamanho do payload seguinte e recortar esse payload com${h[@]:2:0x${h[1]}}É isso que faz de um site um verdadeiro site hacker. Excelente
Não sou bom em programação Bash, mas me surpreende que, na prática, ela seja mais poderosa e menos horrível do que parece
Bash é uma das minhas ferramentas favoritas, junto com vim e Lua
É um binário único de menos de 2 MB, está em todo lugar e faz muito mais coisas do que muita gente imagina
Com shellcheck e boas práticas, Bash também pode ficar legível e seguro
Se precisar de mais alguma coisa, dá para acoplar utilitários em C/C++ sem uma FFI complicada, sem precisar puxar um monte de dependências obscuras com pip/npm
É comum haver scripts Bash inline dentro do CI e do Dockerfile, e também gosto de mover tudo isso para arquivos de script separados. Agora que há shellcheck no CI, esse código também é verificado
Os desenvolvedores reclamam que “piora a legibilidade”, mas, longe de piorar, fica muito mais seguro
O próximo problema é que o mesmo script Bash acaba duplicado no mesmo arquivo em vários repositórios. É uma estrutura de build distribuída; estou aberto a ideias de melhoria
Me arrependo de não ter investido mais tempo nisso quando era estudante. Foi uma das poucas ferramentas que me acompanharam por toda a carreira
Fico me perguntando se ainda existe gente criando servidores customizados para jogos comerciais
A versão Java, em especial, está numa posição relativamente peculiar: continua recebendo grandes atualizações gratuitas de conteúdo, tem suporte oficial para rodar todas as versões antigas, e a comunidade de mods é extremamente ativa
Minecraft (Java) é tanto um jogo quanto algo mais próximo de uma engine de jogos sobre a qual outras pessoas constroem
Elas fazem isso para corrigir bugs, aprender uma linguagem ou ver como as coisas funcionam por dentro
Minecraft é um dos melhores jogos lançados desde Quake
Meu Deus, isso realmente aconteceu
Já li muitos textos sobre abordagens de implementação, mas este é esmagador. É tranquilamente um dos melhores textos de projetos estranhos que já li, e é realmente excelente