hrxdev
Português

#2 Um mundo, saves, replays e um bug escondido no formato de save

· Nick

Desde a primeira entrada, o núcleo ganhou um mundo para guardar as coisas, arquivos para guardá-lo e um jeito de provar que duas execuções concordam. Ainda sem gráficos e sem personagens. Quase tudo aqui é encanamento, e uma parte é um bug que acabou merecendo a história.

Um mundo feito de blocos

O mundo é uma grade de blocos de 1×1 m em níveis discretos, numerados a partir de zero. Uma parede ou uma porta é um bloco; uma escada será uma ligação especial entre dois níveis, mas escadas ainda não são construídas. Internamente, o mundo é um conjunto de camadas planas (piso, estrutura, flags de estrutura) para o mapa todo, mais uma camada derivada de transitabilidade. O mapa é acompanhado em chunks de 16×16 com contadores de versão, para que um renderizador ou uma busca de caminho possa perguntar “essa área mudou?” sem comparar blocos.

O tamanho do mundo é escolhido na criação. A construção é feita por comandos que recebem um retângulo: colocar um piso, uma parede ou uma porta numa área, ou removê-los. Se parte do retângulo estiver bloqueada, o resto é construído mesmo assim e o comando informa o que pulou. Por enquanto há três tipos de bloco embutidos: piso, parede, porta. A partir daqui, o mapa faz parte do hash do mundo e dos saves. Ele é conferido contra um modelo de referência lento e obviamente correto, em milhares de comandos aleatórios.

Saves e replays

Um save e um replay compartilham o mesmo contêiner de arquivo. Ele é dividido em seções com rótulo, cada uma com um tipo e uma versão, para que partes novas do mundo (personagens, necessidades, relações) possam virar seções novas mais tarde sem quebrar arquivos antigos. Há somas de verificação do cabeçalho e do corpo, o corpo é comprimido, e todo tamanho é conferido contra um limite antes de alocar qualquer memória, de modo que um arquivo corrompido ou malicioso não consegue fazer o jogo devorar toda a sua RAM. Os tipos de bloco são salvos pelo nome e não pelo número, então um save continua funcionando se a lista de tipos mudar.

Um replay é o que eu queria desde o começo: uma semente mais o registro de comandos. Ao gravar, o núcleo também escreve um hash de controle a cada 100 ticks. Ao reproduzir um replay, ele compara o resultado de cada comando e cada hash de controle pelo caminho e para no primeiro tick em que divergem. Um replay também lembra qual build do núcleo o gravou, então um replay de outro build é recusado em vez de divergir em silêncio.

O núcleo em si nunca mexe com arquivos: ele lê e escreve streams, e o jogo decide onde os saves ficam. Essa fronteira é imposta pelo mesmo mecanismo de APIs proibidas de antes.

Ainda não está pronto: uma ferramenta de linha de comando que reproduza um arquivo de replay, e a parte do jogo relativa a salvar (uma pasta de saves, autosaves, gravação segura).

Provando que duas execuções concordam

O teste de determinismo agora roda um cenário em dois processos separados: uma semente, um roteiro de comandos de construção (alguns deliberadamente inválidos) e um sistema de teste que consome todos os fluxos aleatórios. Cada processo executa o cenário de três maneiras — direto do início ao fim, passando por um save e uma restauração, e passando por um replay dos bytes gravados — e compara os hashes a cada tick. Depois, os dois processos comparam seus hashes entre si a cada 10 ticks.

Quebrei de propósito para ter certeza de que ele pode falhar. Um Random sem semente contrabandeado para dentro do tick deixou o teste vermelho. Um hash de string randomizado, que o .NET inicializa de forma diferente em cada processo, passou despercebido dentro de um processo só e só foi pego ao comparar dois. É esse o motivo de usar dois processos.

A regra que deixei escrita é que um mesmo build do núcleo deve dar os mesmos hashes em processadores x64 e arm64, de modo que um replay gravado num Mac com chip da Apple precise rodar num PC com Windows. Existem hashes de referência fixados e arquivos de exemplo de save e replay exatamente para essa verificação. A trigonometria e as exponenciais em ponto flutuante diferem entre plataformas, então ficam proibidas em tudo que afeta o mundo; vou precisar de um substituto determinístico na primeira vez que um sistema as exigir. Esses arquivos de referência só mudam de propósito: se um hash se desvia, a mudança precisa ser explicada antes de ser aceita.

Um benchmark capaz de dizer não

Agora existe um executor sem interface que recebe um cenário, uma semente e um número de ticks, e imprime um relatório JSON: mediana e percentil 99 do tempo por tick, o número de agentes, o hash do mundo e quantos bytes foram alocados por tick. O primeiro cenário constrói um mapa de 512×512 em 16 níveis com cerca de 52.000 comandos de construção.

A linha de base é guardada por máquina, porque tempos de um notebook não significam nada num desktop. Uma regressão é uma mediana mais de 10% acima da linha de base ou um percentil 99 mais de 35% acima, e só se o aumento for maior que 0,05 ms, já que um tick quase vazio fica na resolução do cronômetro. Um critério vale em toda máquina: zero bytes alocados por tick. Preciso ser honesto sobre o que isso mede hoje. Sem sistemas e sem personagens, um tick leva uns 35 nanossegundos, então o critério de tempo está quase dormindo até os primeiros sistemas de verdade chegarem no marco de movimento. O critério de alocação é o que trabalha agora.

O teste instável que não era instável

Enquanto montava o benchmark, um teste do formato de save começou a falhar em alguns builds mesmo sem o núcleo ter mudado. O teste inverte cada byte de um replay comprimido, um de cada vez, e exige que o carregador recuse todos. Num build, um byte perto do fim do arquivo foi aceito. No build anterior, o mesmo teste passava.

O motivo de aparecer e sumir: um replay carrega o identificador do build que o escreveu, então os bytes comprimidos mudam de build para build, e com eles o byte que é invertido. A brecha sempre existiu; o teste só a notou quando os dados caíram desse jeito.

A brecha em si: a soma de verificação cobria os dados depois de descomprimidos, e o descompressor do .NET se mostrou tolerante. Ele aceita um stream que nunca diz que terminou e ignora tudo que vem depois do fim. Inverter um bit de preenchimento no último byte gera um stream diferente, mas ainda válido, que descomprime nos mesmos dados; a soma de verificação continuava batendo e um arquivo danificado carregava como se estivesse bom.

A correção é uma nova versão do contêiner. A soma de verificação agora cobre os bytes como estão armazenados no arquivo e é conferida antes de descomprimir, e a descompressão precisa terminar exatamente onde o cabeçalho diz. Um corpo vazio é guardado sem compressão, já que o compressor não escreve nada para uma entrada vazia. As verificações agora usam streams comprimidos fixos, que não dependem do build, além de fuzzing com arquivos danificados. Os arquivos de exemplo foram regenerados para a nova versão; os hashes do mundo não mudaram.

Em outras frentes

O site, este que você está lendo, agora mostra dois contadores de visitas na página inicial: visitantes únicos de todos os tempos e das últimas 24 horas. Eles são contados a partir do log de páginas do servidor web, sem JavaScript, sem cookies e sem serviços externos. Um endereço é guardado apenas como um hash com sal, os bots são filtrados pelo user agent, e os números atrasam alguns minutos. Várias pessoas atrás de uma mesma rede contam como uma.

O que vem a seguir

O primeiro marco, o esqueleto do núcleo, está concluído. O próximo é o M1, a visão de construção em Godot: paredes, portas e salas a partir do modelo de mundo, corte por andar, uma câmera que gira em passos de 90° e cubos simples no lugar de arte. O trabalho ainda não começou. Veja o roteiro.

← Todas as entradas do devlog · Próximo: #3 →