Portando o Quake 3 para a Ferrugem


Nossa equipe Immunant ama Rust e está trabalhando ativamente no C2Rust, uma estrutura de migração que cuida de toda a rotina de migração para o Rust. Nós nos esforçamos para introduzir automaticamente melhorias de segurança no código Rust convertido e ajudar o programador a fazê-lo quando a estrutura falhar. No entanto, antes de tudo, precisamos criar um tradutor confiável que permita que os usuários iniciem o Rust. Os testes em pequenos programas CLI estão lentamente se tornando obsoletos, por isso decidimos transferir o Quake 3. para o Rust. Depois de alguns dias, provavelmente fomos os primeiros a jogar o Quake3 no Rust!

Preparação: Terremoto 3 fontes


Tendo estudado o código fonte do Quake 3 original e vários garfos, decidimos pelo ioquake3 . Este é um fork do Quake 3 criado pela comunidade, que ainda é suportado e construído em plataformas modernas.

Como ponto de partida, decidimos garantir a montagem do projeto em sua forma original:

$ make release 

Ao criar o ioquake3, várias bibliotecas e arquivos executáveis ​​são criados:

 $ tree --prune -I missionpack -P "*.so|*x86_64" . └── build └── debug-linux-x86_64 ├── baseq3 │ ├── cgamex86_64.so # client │ ├── qagamex86_64.so # game server │ └── uix86_64.so # ui ├── ioq3ded.x86_64 # dedicated server binary ├── ioquake3.x86_64 # main binary ├── renderer_opengl1_x86_64.so # opengl1 renderer └── renderer_opengl2_x86_64.so # opengl2 renderer 

Entre essas bibliotecas, as bibliotecas de interface do usuário, cliente e servidor podem ser compiladas como um assembly Quake VM ou como bibliotecas compartilhadas X86 nativas. Em nosso projeto, decidimos usar versões nativas. Traduzir VMs para Rust e usar versões QVM seria muito mais simples, mas queríamos testar completamente o C2Rust.

Em nosso projeto de transferência, focamos na interface do usuário, jogo, cliente, renderizador OpenGL1 e no executável principal. Também podemos traduzir o renderizador OpenGL2, mas decidimos pular isso porque ele usa uma quantidade significativa de .glsl , que o sistema de compilação incorpora como literais de string no código-fonte C. Após a compilação, adicionaremos suporte para scripts de compilação para incorporação Código GLSL em seqüências de caracteres Rust, mas ainda não existe uma boa maneira automatizada de transpor esses arquivos temporários gerados automaticamente. Em vez disso, acabamos de traduzir a biblioteca do renderizador OpenGL1 e forçamos o jogo a usá-lo em vez do renderizador padrão. Além disso, decidimos pular o servidor dedicado e os arquivos de missão empacotados, porque eles não serão difíceis de transferir e não são necessários para a nossa demonstração.

Transpose Quake 3


Para preservar a estrutura de diretórios usada no Quake 3 e não alterar o código-fonte, precisamos obter exatamente os mesmos arquivos binários que no assembly nativo, ou seja, quatro bibliotecas compartilhadas e um arquivo executável.

Como o C2Rust cria os arquivos de montagem do Cargo, cada binário requer seu próprio caixote Rust com o arquivo Cargo.toml correspondente.

Para que o C2Rust crie uma caixa por arquivo binário de saída, ele também precisará de uma lista de arquivos binários com o objeto ou os arquivos de origem correspondentes, bem como uma chamada de vinculador usada para criar cada arquivo binário (usado para determinar outros detalhes, por exemplo, dependências da biblioteca).

No entanto, descobrimos rapidamente uma limitação causada pela maneira como o C2Rust intercepta o processo de compilação nativo: o C2Rust recebe um arquivo de banco de dados de compilação na entrada que contém uma lista de comandos de compilação que são executados durante a compilação. No entanto, esse banco de dados contém apenas comandos de compilação sem chamadas do vinculador. A maioria das ferramentas que criam esse banco de dados tem essa limitação intencional, por exemplo, cmake com CMAKE_EXPORT_COMPILE_COMMANDS , bear e compiledb . Em nossa experiência, a única ferramenta que inclui comandos de build-logger é o criador de build-logger criado pelo CodeChecker , que não usamos porque aprendemos sobre isso somente depois de escrever nossos próprios wrappers (eles são descritos abaixo). Isso significava que, para compilar um programa C com vários arquivos binários, não foi possível usar o arquivo compile_commands.json criado por qualquer uma das ferramentas comuns.

Portanto, escrevemos nossos próprios scripts de compilador e wrapper vinculador que despejam todas as chamadas para o compilador e vinculador ao banco de dados e, em seguida, o convertem no compile_commands.json estendido. Em vez da montagem usual, use um comando como:

 $ make release 

adicionamos wrappers para interceptar o assembly com:

 $ make release CC=/path/to/C2Rust/scripts/cc-wrappers/cc 

Os wrappers criam um diretório de vários arquivos JSON, um por chamada. O segundo script coleta todos eles em um novo arquivo compile_commands.json , que contém os comandos de compilação e compilação. Em seguida, estendemos o C2Rust para que ele leia os comandos de construção do banco de dados e crie uma caixa separada para cada binário vinculado. Além disso, o C2Rust agora também lê dependências da biblioteca para cada arquivo binário e as adiciona automaticamente ao arquivo build.rs da caixa correspondente.

Para melhorar a conveniência, todos os binários podem ser coletados por vez, colocando-os dentro da área de trabalho . O C2Rust cria o arquivo Cargo.toml trabalho de nível Cargo.toml , para que possamos construir o projeto com o único cargo build no diretório quake3-rs :

 $ tree -L 1 . ├── Cargo.lock ├── Cargo.toml ├── cgamex86_64 ├── ioquake3 ├── qagamex86_64 ├── renderer_opengl1_x86_64 ├── rust-toolchain └── uix86_64 $ cargo build --release 

Eliminar rugosidade


Quando tentamos compilar o código traduzido pela primeira vez, encontramos alguns problemas com as fontes do Quake 3: havia casos de fronteira com os quais o C2Rust não conseguia lidar (nem corretamente nem de alguma forma).

Ponteiros de matriz


Vários locais no código-fonte original contêm expressões que apontam para o próximo elemento após o último elemento da matriz. Aqui está um exemplo de código C simplificado:

 int array[1024]; int *p; // ... if (p >= &array[1024]) { // error... } 

O padrão C (veja, por exemplo, C11, Seção 6.5.6 ) permite que ponteiros para um elemento ultrapassem o final de uma matriz. No entanto, Rust proíbe isso, mesmo que apenas tomemos o endereço do elemento. Encontramos exemplos desse padrão na função AAS_TraceClientBBox .

O compilador Rust também sinalizou um exemplo semelhante, mas realmente G_TryPushingEntity , em G_TryPushingEntity , onde a instrução condicional é da forma > , não >= . Um ponteiro que sai dos limites é desreferenciado após a construção condicional, que é um erro de segurança da memória.

Para evitar esse problema no futuro, corrigimos o transpiler C2Rust para que ele use a aritmética do ponteiro para calcular o endereço de um elemento da matriz, em vez de usar a operação de indexação da matriz. Graças a essa correção, o código que usa o padrão semelhante “endereço do elemento no final da matriz” agora está corretamente traduzido e executado sem modificações.

Elementos de matriz de comprimento variável


Lançamos o jogo para testar tudo e imediatamente entramos em pânico com Rust:

 thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 4', quake3-client/src/cm_polylib.rs:973:17 

cm_polylib.c , notamos que ele desreferencia o campo p na seguinte estrutura:

 typedef struct { int numpoints; vec3_t p[4]; // variable sized } winding_t; 

O campo p na estrutura é uma versão do membro da matriz flexível que não é suportada pelo padrão C99, mas ainda é aceito pelo gcc . C2Rust reconhece elementos de matrizes de comprimento variável com a sintaxe C99 ( vec3_t p[] ) e implementa uma heurística simples para também identificar versões desse padrão até C99 (matrizes de tamanhos 0 e 1 no final das estruturas; também encontramos vários exemplos no código-fonte ioquake3).

Alterar a estrutura acima para a sintaxe C99 eliminou o pânico:

 typedef struct { int numpoints; vec3_t p[]; // variable sized } winding_t; 

Uma tentativa de corrigir automaticamente esse padrão no caso geral (com tamanhos de matriz diferentes de 0 e 1) será extremamente difícil, porque teremos que distinguir entre matrizes comuns e elementos de matrizes de tamanho variável de tamanhos arbitrários. Portanto, em vez disso, recomendamos que você corrija o código C original manualmente, como fizemos com ioquake3.

Operandos vinculados no código assembler embutido


Outra fonte de falha foi esse código do assembler C-assembler no cabeçalho do sistema /usr/include/bits/select.h :

 # define __FD_ZERO(fdsp) \ do { \ int __d0, __d1; \ __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS \ : "=c" (__d0), "=D" (__d1) \ : "a" (0), "0" (sizeof (fd_set) \ / sizeof (__fd_mask)), \ "1" (&__FDS_BITS (fdsp)[0]) \ : "memory"); \ } while (0) 

definindo a versão interna da macro __FD_ZERO . Essa definição levanta um caso raro de fronteira de E / S de operandos vinculados gcc : com tamanhos diferentes. O operador de saída "=D" (__d1) vincula o registro edi à variável __d1 como um valor de 32 bits e "1" (&__FDS_BITS (fdsp)[0]) vincula o mesmo registro ao endereço fdsp->fds_bits como um ponteiro de 64 bits. gcc e clang resolvem essa incompatibilidade. usando o registro de 64 bits rdi e truncando seu valor antes de atribuir o valor __d1 , e Rust usa a semântica do LLVM por padrão, no qual esse caso permanece indefinido. Nas compilações de depuração (não nas versões, que se comportaram bem), vimos que os dois operandos podem ser atribuídos ao registrador edi , pelo qual o ponteiro é truncado para 32 bits antes do código do assembler interno, o que causa falhas.

Como rustc passa o código do assembler Rust embutido para o LLVM com muito poucas alterações, decidimos corrigir esse caso específico no C2Rust. Implementamos um novo engradado c2rust-asm-casts que c2rust-asm-casts esse problema graças ao sistema do tipo Rust, usando funções de característica e auxiliar que expandem e truncam automaticamente operandos vinculados a um tamanho interno grande o suficiente para acomodar os dois operandos. O código acima traduz corretamente para o seguinte:

 let mut __d0: c_int = 0; let mut __d1: c_int = 0; // Reference to the output value of the first operand let fresh5 = &mut __d0; // The internal storage for the first tied operand let fresh6; // Reference to the output value of the second operand let fresh7 = &mut __d1; // The internal storage for the second tied operand let fresh8; // Input value of the first operand let fresh9 = (::std::mem::size_of::<fd_set>() as c_ulong).wrapping_div(::std::mem::size_of::<__fd_mask>() as c_ulong); // Input value of the second operand let fresh10 = &mut *fdset.__fds_bits.as_mut_ptr().offset(0) as *mut __fd_mask; asm!("cld; rep; stosq" : "={cx}" (fresh6), "={di}" (fresh8) : "{ax}" (0), // Cast the input operands into the internal storage type // with optional zero- or sign-extension "0" (AsmCast::cast_in(fresh5, fresh9)), "1" (AsmCast::cast_in(fresh7, fresh10)) : "memory" : "volatile"); // Cast the operands out (types are inferred) with truncation AsmCast::cast_out(fresh5, fresh9, fresh6); AsmCast::cast_out(fresh7, fresh10, fresh8); 

É importante notar que esse código não requer nenhum tipo de valores de entrada e saída na montagem do código do assembler; ao resolver conflitos de tipo, fresh6 fresh8 Rust (principalmente tipos fresh6 e fresh8 ).

Variáveis ​​globais alinhadas


A última fonte da falha foi a seguinte variável global que armazena a constante SSE:

 static unsigned char ssemask[16] __attribute__((aligned(16))) = { "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x00\x00\x00\x00" }; 

Atualmente, o Rust suporta o atributo de alinhamento para tipos estruturais, mas não para variáveis ​​globais, ou seja, elementos static . Nós pensamos em maneiras de resolver esse problema no caso geral, no Rust ou no C2Rust, mas por enquanto no ioquake3 decidimos corrigi-lo manualmente com um pequeno arquivo de patch . Esse arquivo de correção substitui o equivalente ao ssemask do Rust ssemask seguinte:

 #[repr(C, align(16))] struct SseMask([u8; 16]); static mut ssemask: SseMask = SseMask([ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, ]); 

Executando o quake3-rs


Quando a cargo build --release , os binários são criados, mas são criados sob target/release com uma estrutura de diretório que o binário ioquake3 não reconhece. Escrevemos um script que cria links simbólicos no diretório atual para recriar a estrutura de diretórios correta (incluindo links para arquivos .pk3 contendo recursos do jogo):

 $ /path/to/make_quake3_rs_links.sh /path/to/quake3-rs/target/release /path/to/paks 

O caminho /path/to/paks deve apontar para o diretório que contém os arquivos .pk3 .

Agora vamos rodar o jogo! Precisamos passar +set vm_game 0 , etc., portanto, carregamos esses módulos como bibliotecas compartilhadas Rust, e não como um assembly QVM, além de cl_renderer para usar o renderizador OpenGL1.

 $ ./ioquake3 +set sv_pure 0 +set vm_game 0 +set vm_cgame 0 +set vm_ui 0 +set cl_renderer "opengl1" 

E ...


Lançamos o Quake3 no Rust!


Aqui está um vídeo de como transpomos o Quake 3, baixamos o jogo e jogamos um pouco:


Você pode estudar as fontes transpiladas no ramo transpiled do nosso repositório. Também há uma ramificação refactored contendo as mesmas fontes com vários comandos de refatoração pré-aplicados.

Como transpor


Se você quiser transpor o Quake 3 e executá-lo, lembre-se de que precisará de seus próprios recursos de jogo ou recursos de demonstração do Quake 3 da Internet. Você também precisará instalar o C2Rust (no momento da redação, a versão noturna necessária é nightly-2019-12-05 , mas recomendamos que você procure no repositório do C2Rust ou em crates.io para encontrar a versão mais recente):

 $ cargo +nightly-2019-12-05 install c2rust 

e cópias de nossos repositórios C2Rust e ioquake3:

 $ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/c2rust.git $ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/ioq3.git 

Como alternativa à instalação do c2rust usando o comando acima, você pode criar o C2Rust manualmente usando cargo build --release . De qualquer forma, o repositório C2Rust ainda será necessário, pois contém os scripts de wrapper do compilador necessários para transpor o ioquake3.

ssemask um script que transporta automaticamente o código C e aplica o patch ssemask . Para usá-lo, execute o seguinte comando no nível superior do repositório ioq3 :

 $ ./transpile.sh </path/to/C2Rust repository> </path/to/c2rust binary> 

Este comando deve criar um subdiretório quake3-rs contendo o código Rust, para o qual você pode executar o cargo build --release e as etapas restantes descritas acima.

Source: https://habr.com/ru/post/pt483142/


All Articles