De onde veio a ideia
Há um tempo escrevi por aqui sobre MU Online, o clássico que ainda resiste ao tempo — como um MMORPG de 2001 continua vivo até hoje graças a uma comunidade gigantesca de servidores privados. Escrever aquele post me deixou curioso sobre um ângulo diferente do jogo: não a experiência de jogar, mas o que existe por trás da tela. Como um cliente C++ de mais de vinte anos conversa com um servidor? Como funciona a criptografia dos pacotes? Como centenas de jogadores se veem se movendo no mesmo mapa sem o servidor cair de joelhos?
A resposta virou o rbemulator: um emulador do servidor de MU Online (temporada 0.97k) escrito do zero em Ruby.
- Repositório: github.com/joaopaulocorreia/rbemulator
O que é o rbemulator
O objetivo do projeto é simples de enunciar e difícil de entregar: fazer o cliente 0.97k original, sem nenhuma modificação, conseguir conectar num servidor escrito em Ruby, logar e jogar. Nada de emular o protocolo "de forma aproximada" — os bytes que saem do servidor precisam ser exatamente os bytes que aquele executável antigo espera receber, byte a byte, incluindo alinhamentos de struct herdados do compilador MSVC original e uma cifra própria (SimpleModulus + tabelas Xor) que não é a cifra padrão usada por outros emuladores mais recentes.
O projeto nasceu como um port de uma implementação irmã em Go (muemu), mas hoje é totalmente independente: vive no seu próprio repositório, com seus próprios dados e sua própria história de commits. A arquitetura, porém, continua inspirada tanto naquele projeto Go quanto no OpenMU, um rewrite de referência em C# cuja documentação de pacotes serviu de spec de protocolo.
Arquitetura em camadas
O fluxo de dados segue uma separação estrita, emprestada do OpenMU:
bytes recebidos → handler (parse byte→struct) → action (lógica de jogo, não sabe de bytes)
lógica de jogo → view (serializa evento→pacote) → bytes enviados
- Handlers só sabem transformar bytes crus em estruturas Ruby. Não têm regra de negócio.
- Actions são a lógica de jogo pura — login, criar personagem, atacar, morrer — e nunca tocam em um byte sequer.
- Views fazem o caminho inverso: pegam um evento de jogo e serializam de volta para o formato binário exato que o cliente espera.
Essa fronteira rígida é o que permite testar a lógica de jogo sem precisar simular sockets, e trocar detalhes de protocolo sem mexer em regra de negócio.
Concorrência: fibers no lugar de goroutines
A decisão mais central do projeto foi como lidar com centenas de conexões simultâneas sem a complexidade de locks espalhados pelo código. A resposta escolhida foi a gem async, que usa fibers cooperativas rodando em um único reator — o análogo Ruby mais próximo das goroutines do projeto Go original.
Na prática:
- Uma fiber de leitura por conexão, que faz o parse dos pacotes recebidos.
- Uma fiber de escrita dedicada por conexão, alimentada por uma
Async::LimitedQueue, equivalente à goroutine de escrita + channel do Go — isso serializa as escritas com segurança sem precisar de mutex. - Chamadas de socket da stdlib fazem yield automaticamente sob o scheduler de fibers do
async, então o código de I/O parece síncrono mas nunca bloqueia o reator. - Como existe um único reator por processo, o estado por conexão nunca é acessado por duas fibers ao mesmo tempo — data races são eliminadas por construção, não por disciplina.
O modelo de escala é por processo, não por thread: cada GameServer mira 500–1000 jogadores e roda como seu próprio processo com seu próprio reator. Para escalar horizontalmente, a ideia é rodar mais processos atrás de SO_REUSEPORT, não adicionar mais threads dentro de um processo só.
O mundo como mapas-atores
Uma das partes mais interessantes de arquitetar foi o mundo em si. Cada mapa é um ator: uma fiber dona de todo o estado daquele mapa (jogadores presentes, monstros, posições), que só aceita mudanças através de comandos enfileirados numa Async::Queue.
- Comandos críticos como entrar e sair do mapa são bloqueantes — o chamador espera confirmação.
- Comandos de alta frequência como mover e falar usam
try_submit: se a fila estiver cheia, o comando é descartado em vez de acumular atraso. Perder um frame de movimento é inofensivo; travar o mapa inteiro esperando por ele não é.
Para que a visão de "quem está por perto" não vire uma varredura O(n) em todo jogador do mapa a cada movimento, o mapa é indexado por setores espaciais (uma grade de 8 tiles). Uma atualização de viewport só precisa varrer os setores vizinhos, não o mapa inteiro — o custo por movimento é O(vizinhança), não O(população do mapa).
Monstros vivem nesse mesmo mundo: cada mapa spawna os seus, faz eles vagarem a cada tick, e quando um jogador ataca, o dano é resolvido no próprio ator do mapa (checagem de alcance, rolagem baseada em Força menos defesa, mínimo de 1 de dano). Uma morte dispara o pacote de recompensa com experiência, aplica level-up seguindo a curva autêntica do jogo (10*(level+9)*level², 5 pontos por nível, cura total) e reenvia os stats derivados — tudo isso sem timers por entidade: os prazos de respawn e os cooldowns de ataque são processados no próprio tick do ator.
Protocolo e criptografia do 0.97k
MU Online usa pacotes que começam com um byte de cabeçalho 0xC1–0xC4, indicando se o tamanho vem em 1 ou 2 bytes e se o pacote está criptografado. No GameServer da versão 0.97k, o cliente manda praticamente tudo como C3 (criptografado), e o servidor decifra usando Xor32 + SimpleModulus — uma cifra de bloco própria da época, cujas chaves específicas do 0.97k não são as mesmas usadas por padrão em emuladores mais modernos (como os de Season 6). Elas precisaram ser portadas e validadas byte a byte contra vetores de referência.
Outro detalhe traiçoeiro: nem todos os pacotes do cliente C++ original usam pack(1). Alguns têm padding de alinhamento herdado do compilador MSVC (WORD alinhado em 2 bytes, DWORD em 4), e o cliente espera esse padding exatamente onde o C++ original o colocaria. Errar esse detalhe não gera um erro óbvio — gera sintomas sutis como classe de personagem errada, level exibido como 0, ou barra de HP que não atualiza. Foi, sem dúvida, a armadilha mais cara do projeto até agora.
Persistência: Postgres via Sequel
Contas, personagens, itens e até as definições de jogo (monstros, pontos de spawn) vivem em Postgres, acessado via Sequel em vez de ActiveRecord — uma escolha de performance quase-crua e melhor encaixe com o reator de fibers (os repositórios usam datasets do Sequel internamente, mas devolvem structs de domínio simples, então a lógica de jogo continua agnóstica ao ORM). As definições de monstros e spawns são importadas diretamente dos arquivos .txt originais do MuEmu por um importer idempotente, e carregadas em memória no boot — nunca consultadas por tick.
Os backends são intercambiáveis atrás do mesmo contrato de repositório: passar --db <url> liga ao Postgres, omitir usa um repositório em memória, útil para testes e para rodar rápido sem subir infraestrutura.
O que já funciona hoje
A fatia vertical de hoje já cobre um caminho e tanto:
- Crypto completo (SimpleModulus + Xor3/Xor32), validado contra vetores de referência.
- Connect server servindo a lista de servidores ao cliente.
- Login no game server (decriptação da credencial, autenticação via bcrypt).
- Lista, criação, seleção e exclusão de personagens, com stats iniciais autênticos por classe.
- Entrada no mundo, com HP/MP derivados por fórmulas específicas de cada classe.
- Mapas como atores, com viewport, movimento e chat entre múltiplos jogadores simultâneos.
- Monstros com spawn, wandering e IA de agressão/perseguição com leash.
- Combate corpo a corpo completo: dano, morte, recompensa de experiência, level-up e tela de morte/respawn.
Tudo isso testado ponta a ponta com clientes reais conectados via socket criptografado — inclusive um teste de carga opcional que sobe até 1000 jogadores simultâneos simulados e mede tempo de onboarding, memória e throughput de movimento/broadcast.
Próximos passos
O que já foi feito na implementação irmã em Go e ainda não foi portado para o Ruby: sistema de drops ponderado (baseado no ItemDrop.txt original), opções de item, lojas (NPCs vendedores) e os pacotes de entrada de inventário. É o caminho natural para transformar a fatia vertical atual em algo jogável de verdade, com progressão de equipamento.
Conclusão
O rbemulator é, ao mesmo tempo, um exercício de arqueologia de protocolo e um projeto de arquitetura Ruby "de verdade": camadas limpas, concorrência sem locks via fibers, e um mundo de jogo modelado como atores independentes. É também a resposta prática para aquela curiosidade que ficou depois de escrever sobre a nostalgia de MU Online — entender, byte a byte, o que fazia aquele mundo funcionar. O código é aberto e o repositório tem um README detalhado com instruções de setup via Docker ou localmente, para quem quiser rodar o próprio servidor ou simplesmente ler como um cliente de 2001 ainda se conecta hoje.