Portar Quake 3 a Rust


Nuestro equipo Immunant ama a Rust y está trabajando activamente en C2Rust, un marco de migración que se encarga de toda la rutina de migrar a Rust. Nos esforzamos por introducir automáticamente mejoras de seguridad en el código Rust convertido y ayudar al programador a hacerlo él mismo cuando falla el marco. Sin embargo, antes que nada, necesitamos crear un traductor confiable que permita a los usuarios comenzar a usar Rust. Las pruebas en pequeños programas CLI se están volviendo obsoletas lentamente, por lo que decidimos transferir Quake 3 a Rust. Después de un par de días, ¡probablemente fuimos los primeros en jugar Quake3 en Rust!

Preparación: Quake 3 fuentes


Después de estudiar el código fuente del Quake 3 original y varios tenedores, nos decidimos por ioquake3 . Esta es una bifurcación creada por la comunidad de Quake 3, que todavía es compatible y está construida en plataformas modernas.

Como punto de partida, decidimos asegurarnos de que podemos armar el proyecto en su forma original:

$ make release 

Al construir ioquake3, se crean varias bibliotecas y archivos ejecutables:

 $ 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 estas bibliotecas, las bibliotecas de UI, cliente y servidor se pueden compilar como un ensamblaje Quake VM o como bibliotecas compartidas X86 nativas. En nuestro proyecto, decidimos usar versiones nativas. Traducir máquinas virtuales a Rust y usar versiones de QVM sería mucho más simple, pero queríamos probar a fondo C2Rust.

En nuestro proyecto de transferencia, nos centramos en la interfaz de usuario, el juego, el cliente, el renderizador OpenGL1 y el ejecutable principal. También podríamos traducir el renderizador OpenGL2, pero decidimos omitir esto porque usa una cantidad significativa de .glsl sombreador .glsl , que el sistema de compilación incorpora como literales de cadena en el código fuente C. Después de la compilación, agregaremos soporte para los scripts de compilación para incrustar Código GLSL en cadenas Rust, pero todavía no hay una buena forma automatizada de transponer estos archivos temporales generados automáticamente. Entonces, en su lugar, solo tradujimos la biblioteca de renderizador OpenGL1 y forzamos al juego a usarla en lugar del renderizador predeterminado. Además, decidimos omitir el servidor dedicado y los archivos de misión empaquetados, porque no serán difíciles de transferir y no son necesarios para nuestra demostración.

Transponer Quake 3


Para preservar la estructura de directorios utilizada en Quake 3 y no cambiar el código fuente, necesitábamos obtener exactamente los mismos archivos binarios que en el ensamblado nativo, es decir, cuatro bibliotecas compartidas y un archivo ejecutable.

Como C2Rust crea los archivos de ensamblaje de Cargo, cada binario requiere su propia caja Rust con el archivo Cargo.toml correspondiente.

Para que C2Rust cree una caja por archivo binario de salida, también necesitará una lista de archivos binarios con el objeto o los archivos fuente correspondientes, así como una llamada de enlazador utilizada para crear cada archivo binario (utilizado para determinar otros detalles, por ejemplo, dependencias de la biblioteca).

Sin embargo, rápidamente encontramos una limitación causada por la forma en que C2Rust intercepta el proceso de compilación nativo: C2Rust recibe en la entrada un archivo de base de datos de compilación que contiene una lista de comandos de compilación que se ejecutan durante la compilación. Sin embargo, esta base de datos contiene solo comandos de compilación sin llamadas de enlazador. La mayoría de las herramientas que crean esta base de datos tienen esta limitación intencional, por ejemplo, cmake con CMAKE_EXPORT_COMPILE_COMMANDS , bear y compiledb . En nuestra experiencia, la única herramienta que incluye comandos de build-logger es el build-logger de CodeChecker creado por CodeChecker , que no utilizamos porque solo lo aprendimos después de escribir nuestros propios contenedores (se describen a continuación). Esto significaba que para compilar un programa en C con varios archivos binarios, no podíamos usar el archivo compile_commands.json creado por ninguna de las herramientas comunes.

Por lo tanto, escribimos nuestros propios scripts de envoltura de compilador y enlazador que vuelcan todas las llamadas al compilador y enlazador a la base de datos, y luego lo convierten en compile_commands.json extendido. En lugar del ensamblaje habitual usando un comando como:

 $ make release 

agregamos envoltorios para interceptar el ensamblaje con:

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

Los contenedores crean un directorio de varios archivos JSON, uno por llamada. El segundo script los recopila a todos en un nuevo archivo compile_commands.json , que contiene tanto los comandos de compilación como los de compilación. Luego ampliamos C2Rust para que lea los comandos de compilación de la base de datos y cree una caja separada para cada binario vinculado. Además, C2Rust ahora también lee las dependencias de la biblioteca para cada archivo binario y las agrega automáticamente al archivo build.rs de la caja correspondiente.

Para mejorar la comodidad, todos los archivos binarios se pueden recopilar a la vez colocándolos dentro del espacio de trabajo . C2Rust crea el archivo Cargo.toml espacio de trabajo de nivel Cargo.toml , por lo que podemos construir el proyecto con el único cargo build en el directorio 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 la aspereza


Cuando intentamos compilar el código traducido por primera vez, nos encontramos con un par de problemas con las fuentes de Quake 3: hubo casos límite que C2Rust no pudo manejar (ni correctamente, ni de ninguna manera).

Punteros de matriz


Varios lugares en el código fuente original contienen expresiones que apuntan al siguiente elemento después del último elemento de la matriz. Aquí hay un ejemplo de código C simplificado:

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

El estándar C (véase, por ejemplo, C11, Sección 6.5.6 ) permite que los punteros a un elemento vayan más allá del final de una matriz. Sin embargo, Rust prohíbe esto, incluso si solo tomamos la dirección del elemento. Encontramos ejemplos de dicho patrón en la función AAS_TraceClientBBox .

El compilador Rust también señaló un ejemplo similar, pero en realidad con G_TryPushingEntity en G_TryPushingEntity , donde la instrucción condicional tiene la forma > , no >= . Un puntero que se sale de los límites se desreferencia después de la construcción condicional, que es un error de seguridad de la memoria.

Para evitar este problema en el futuro, arreglamos el transpilador C2Rust para que use la aritmética de puntero para calcular la dirección de un elemento de matriz, en lugar de usar la operación de indexación de matriz. Gracias a esta solución, el código que utiliza el patrón similar "dirección del elemento al final de la matriz" ahora se traduce correctamente y se ejecuta sin modificaciones.

Elementos de matriz de longitud variable


Lanzamos el juego para probarlo todo, e inmediatamente recibimos pánico de 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 

Echando un vistazo a cm_polylib.c , notamos que desreferencia el campo p en la siguiente estructura:

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

El campo p en la estructura es una versión del miembro de matriz flexible que no es compatible con el estándar C99, pero que aún es aceptado por gcc . C2Rust reconoce elementos de matrices de longitud variable con la sintaxis C99 ( vec3_t p[] ) e implementa una heurística simple para identificar también versiones de este patrón antes de C99 (matrices de tamaños 0 y 1 al final de las estructuras; también encontramos varios ejemplos en el código fuente ioquake3).

Cambiar la estructura anterior a la sintaxis C99 eliminó el pánico:

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

Un intento de corregir automáticamente este patrón en el caso general (con tamaños de matriz diferentes de 0 y 1) será extremadamente difícil, ya que tendremos que distinguir entre matrices ordinarias y elementos de matrices de longitud variable de tamaños arbitrarios. Por lo tanto, le recomendamos que corrija el código C original manualmente, como hicimos con ioquake3.

Operandos atados en código ensamblador en línea


Otra fuente de fallas fue este código de ensamblador C-assembler del encabezado del 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) 

definiendo la versión interna de la macro __FD_ZERO . Esta definición plantea un raro caso límite de gcc : operandos vinculados de E / S con diferentes tamaños. El operador de salida "=D" (__d1) vincula el registro edi a la variable __d1 como un valor de 32 bits, y "1" (&__FDS_BITS (fdsp)[0]) vincula el mismo registro a la dirección fdsp->fds_bits como un puntero de 64 bits. gcc y clang resuelven este desajuste. usando el registro rdi 64 bits y truncando su valor antes de asignar el valor __d1 , y Rust usa la semántica LLVM por defecto, en cuyo caso permanece indefinido. En las compilaciones de depuración (no en las versiones, que se comportaron bien), vimos que ambos operandos pueden asignarse al registro edi , por lo que el puntero se trunca a 32 bits antes del código ensamblador incorporado, lo que causa fallas.

Dado que rustc pasa el código de ensamblador Rust incorporado a LLVM con muy pocos cambios, decidimos arreglar este caso particular en C2Rust. Implementamos la nueva caja c2rust-asm-casts , que c2rust-asm-casts este problema gracias al sistema de tipo Rust que utiliza funciones de rasgo y ayuda que automáticamente expanden y truncan los operandos atados a un tamaño interno que es lo suficientemente grande como para contener ambos operandos. El código anterior se traduce correctamente a lo siguiente:

 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); 

Vale la pena señalar que este código no requiere ningún tipo de valores de entrada y salida en el ensamblaje del código del ensamblador; al resolver conflictos de tipos, confiando en ellos para generar tipos Rust (principalmente tipos fresh6 y fresh8 ).

Variables globales alineadas


La última fuente del error fue la siguiente variable global que almacenaba la 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" }; 

Rust actualmente admite el atributo de alineación para tipos estructurales, pero no para variables globales, es decir elementos static Consideramos formas de resolver este problema en el caso general, ya sea en Rust o en C2Rust, pero por ahora en ioquake3 decidimos solucionarlo manualmente con un pequeño archivo de parche . Este archivo de parche reemplaza el equivalente de Rust ssemask siguiente:

 #[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, ]); 

Running quake3-rs


Cuando se cargo build --release , se crean binarios, pero se crean bajo target/release con una estructura de directorio que el binario ioquake3 no reconoce. Escribimos un script que crea enlaces simbólicos en el directorio actual para recrear la estructura de directorios correcta (incluidos los enlaces a archivos .pk3 que contienen recursos del juego):

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

La ruta /path/to/paks debe apuntar al directorio que contiene los archivos .pk3 .

Ahora vamos a correr el juego! Necesitamos pasar +set vm_game 0 , etc., por lo que +set vm_game 0 estos módulos como bibliotecas compartidas Rust, y no como un conjunto QVM, así como cl_renderer para usar el renderizador OpenGL1.

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

Y ...


¡Lanzamos Quake3 en Rust!


Aquí hay un video de cómo transponemos Quake 3, descargamos el juego y jugamos un poco:


Puede estudiar las fuentes transpiladas en la rama transpiled de nuestro repositorio. También hay una rama refactored contiene las mismas fuentes con varios comandos de refactorización preaplicados .

Cómo transponer


Si desea intentar transponer Quake 3 y ejecutarlo usted mismo, tenga en cuenta que necesitará sus propios recursos de juego Quake 3 o recursos de demostración de Internet. También necesitará instalar C2Rust (al momento de escribir, la versión nocturna requerida es nightly-2019-12-05 , pero le recomendamos que busque en el repositorio de C2Rust o en crates.io para encontrar la última versión):

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

y copias de nuestros repositorios C2Rust y 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 a la instalación de c2rust usando el comando anterior, puede construir C2Rust manualmente usando cargo build --release . En cualquier caso, aún se necesitará el repositorio C2Rust, porque contiene los scripts de envoltura del compilador necesarios para transponer ioquake3.

Hemos publicado un script que transporta automáticamente el código C y aplica el parche ssemask . Para usarlo, ejecute el siguiente comando desde el nivel superior del repositorio ioq3 :

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

Este comando debe crear un subdirectorio quake3-rs contenga el código Rust, para el cual puede ejecutar el cargo build --release y los pasos restantes descritos anteriormente.

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


All Articles