Portage de Quake 3 sur Rust


Notre Ă©quipe Immunant aime Rust et travaille activement sur C2Rust, un cadre de migration qui prend en charge toute la routine de migration vers Rust. Nous nous efforçons d'introduire automatiquement des amĂ©liorations de sĂ©curitĂ© dans le code Rust converti et d'aider le programmeur Ă  le faire lui-mĂȘme lorsque le framework Ă©choue. Cependant, tout d'abord, nous devons crĂ©er un traducteur fiable qui permet aux utilisateurs de dĂ©marrer avec Rust. Les tests sur les petits programmes CLI deviennent lentement obsolĂštes, nous avons donc dĂ©cidĂ© de transfĂ©rer Quake 3. vers Rust. AprĂšs quelques jours, nous Ă©tions probablement les premiers Ă  jouer Ă  Quake3 sur Rust!

Préparation: Quake 3 sources


AprÚs avoir étudié le code source du Quake 3 original et diverses fourches, nous nous sommes installés sur ioquake3 . Il s'agit d'un fork créé par la communauté de Quake 3, qui est toujours pris en charge et construit sur des plateformes modernes.

Comme point de départ, nous avons décidé de nous assurer que nous pouvons assembler le projet dans sa forme originale:

$ make release 

Lors de la construction d'ioquake3, plusieurs bibliothÚques et fichiers exécutables sont créés:

 $ 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 

Parmi ces bibliothĂšques, l'interface utilisateur, les bibliothĂšques client et serveur peuvent ĂȘtre compilĂ©es soit en tant qu'assembly VM Quake , soit en tant que bibliothĂšques partagĂ©es natives X86. Dans notre projet, nous avons dĂ©cidĂ© d'utiliser des versions natives. Traduire des machines virtuelles en Rust et utiliser des versions QVM serait beaucoup plus simple, mais nous voulions tester en profondeur C2Rust.

Dans notre projet de transfert, nous nous sommes concentrés sur l'interface utilisateur, le jeu, le client, le moteur de rendu OpenGL1 et le principal exécutable. Nous pourrions également traduire le moteur de rendu OpenGL2, mais nous avons décidé de l'ignorer car il utilise une quantité importante de .glsl shader .glsl , que le systÚme de construction incorpore sous forme de littéraux de chaßne dans le code source C. AprÚs la compilation, nous ajouterons la prise en charge des scripts de construction pour l'intégration Code GLSL en chaßnes Rust, mais il n'y a toujours pas de bon moyen automatisé de transposer ces fichiers temporaires générés automatiquement. Donc, à la place, nous venons de traduire la bibliothÚque de rendu OpenGL1 et avons forcé le jeu à l'utiliser à la place du rendu par défaut. De plus, nous avons décidé d'ignorer le serveur dédié et les fichiers de mission packagés, car ils ne seront pas difficiles à transférer et ils ne sont pas nécessaires pour notre démonstration.

Transposer le séisme 3


Afin de conserver la structure de rĂ©pertoires utilisĂ©e dans Quake 3 et de ne pas changer le code source, nous avions besoin d'obtenir exactement les mĂȘmes fichiers binaires que dans l'assembly natif, c'est-Ă -dire quatre bibliothĂšques partagĂ©es et un exĂ©cutable.

Étant donnĂ© que C2Rust crĂ©e les fichiers d'assemblage Cargo, chaque binaire nĂ©cessite sa propre caisse Rust avec le fichier Cargo.toml correspondant.

Pour que C2Rust crée une caisse par fichier binaire de sortie, il aura également besoin d'une liste de fichiers binaires avec l'objet ou les fichiers source correspondants, ainsi qu'un appel de l'éditeur de liens utilisé pour créer chaque fichier binaire (utilisé pour déterminer d'autres détails, par exemple, les dépendances de bibliothÚque).

Cependant, nous avons rapidement rencontré une limitation causée par la façon dont C2Rust intercepte le processus de génération natif: C2Rust reçoit en entrée un fichier de base de données de compilation qui contient une liste de commandes de compilation qui sont exécutées pendant la génération. Cependant, cette base de données contient uniquement des commandes de compilation sans appels de l'éditeur de liens. La plupart des outils créant cette base de données ont cette limitation intentionnelle, par exemple cmake avec CMAKE_EXPORT_COMPILE_COMMANDS , bear et compiledb . D'aprÚs notre expérience, le seul outil qui inclut des commandes de build-logger est le build-logger créé par CodeChecker , que nous n'avons pas utilisé car nous ne l'avons appris qu'aprÚs avoir écrit nos propres wrappers (ils sont décrits ci-dessous). Cela signifiait que pour compiler un programme C avec plusieurs fichiers binaires, nous ne pouvions pas utiliser le fichier compile_commands.json créé par l'un des outils courants.

Par conséquent, nous avons écrit nos propres scripts de wrapper de compilateur et de lieur qui transfÚrent tous les appels vers le compilateur et le lieur vers la base de données, puis les convertissons en compile_commands.json étendu. Au lieu de l'assemblage habituel, utilisez une commande comme:

 $ make release 

nous avons ajouté des wrappers pour intercepter l'assembly avec:

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

Les wrappers créent un répertoire de plusieurs fichiers JSON, un par appel. Le deuxiÚme script les collecte tous dans un nouveau fichier compile_commands.json , qui contient à la fois les commandes de compilation et de compilation. Ensuite, nous avons étendu C2Rust pour qu'il lise les commandes de construction de la base de données et crée une caisse séparée pour chaque binaire lié. De plus, C2Rust lit désormais les dépendances de bibliothÚque pour chaque fichier binaire et les ajoute automatiquement au fichier build.rs de la caisse correspondante.

Pour amĂ©liorer la commoditĂ©, tous les fichiers binaires peuvent ĂȘtre collectĂ©s Ă  la fois en les plaçant dans l' espace de travail . C2Rust crĂ©e le fichier d'espace de travail de niveau supĂ©rieur Cargo.toml , afin que nous puissions crĂ©er le projet avec la seule cargo build dans le 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 

Élimine la rugositĂ©


Lorsque nous avons essayé de compiler le code traduit pour la premiÚre fois, nous avons rencontré quelques problÚmes avec les sources de Quake 3: il y avait des cas limites que C2Rust ne pouvait pas gérer (ni correctement, ni du tout en quelque sorte).

Pointeurs de tableau


Plusieurs emplacements dans le code source d'origine contiennent des expressions qui pointent vers l'élément suivant aprÚs le dernier élément du tableau. Voici un exemple de code C simplifié:

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

La norme C (voir, par exemple, C11, Section 6.5.6 ) permet aux pointeurs vers un Ă©lĂ©ment d'aller au-delĂ  de la fin d'un tableau. Cependant, Rust l'interdit, mĂȘme si nous ne prenons que l'adresse de l'Ă©lĂ©ment. Nous avons trouvĂ© des exemples d'un tel modĂšle dans la fonction AAS_TraceClientBBox .

Le compilateur Rust a Ă©galement signalĂ© un exemple similaire, mais en fait G_TryPushingEntity dans G_TryPushingEntity , oĂč l'instruction conditionnelle est de la forme > , pas >= . Un pointeur qui sort des limites est ensuite dĂ©rĂ©fĂ©rencĂ© aprĂšs la construction conditionnelle, qui est un bogue de sĂ©curitĂ© de la mĂ©moire.

Pour éviter ce problÚme à l'avenir, nous avons corrigé le transpileur C2Rust afin qu'il utilise l'arithmétique du pointeur pour calculer l'adresse d'un élément de tableau, plutÎt que d'utiliser l'opération d'indexation du tableau. Grùce à ce correctif, le code qui utilise le modÚle similaire «adresse d'élément à la fin du tableau» est maintenant correctement traduit et exécuté sans modifications.

ÉlĂ©ments de tableau Ă  longueur variable


Nous avons lancé le jeu pour tout tester et avons immédiatement eu la panique 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 

En cm_polylib.c un Ɠil Ă  cm_polylib.c , nous avons remarquĂ© qu'il dĂ©rĂ©fĂ©rence le champ p dans la structure suivante:

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

Le champ p dans la structure est une version du membre du tableau flexible qui n'est pas prise en charge par la norme C99, mais qui est toujours acceptée par gcc . C2Rust reconnaßt les éléments des tableaux de longueur variable avec la syntaxe C99 ( vec3_t p[] ) et implémente une heuristique simple pour identifier également les versions de ce modÚle avant C99 (tableaux de tailles 0 et 1 à la fin des structures; nous avons également trouvé plusieurs exemples de ce type dans le code source ioquake3).

Changer la structure ci-dessus en syntaxe C99 a éliminé la panique:

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

Une tentative de corriger automatiquement ce modĂšle dans le cas gĂ©nĂ©ral (avec des tailles de tableau diffĂ©rentes de 0 et 1) sera extrĂȘmement difficile, car nous devrons faire la distinction entre les tableaux ordinaires et les Ă©lĂ©ments de tableaux de longueur variable de tailles arbitraires. Par consĂ©quent, nous vous recommandons plutĂŽt de corriger manuellement le code C d'origine, comme nous l'avons fait avec ioquake3.

Opérandes liés dans le code assembleur en ligne


Une autre source de plantages Ă©tait ce code assembleur C-assembler de l'en-tĂȘte systĂšme /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) 

dĂ©finir la version interne de la macro __FD_ZERO . Cette dĂ©finition soulĂšve un cas limite rare d' E / S d'opĂ©randes liĂ©s gcc : avec diffĂ©rentes tailles. L'opĂ©rateur de sortie "=D" (__d1) lie le registre edi Ă  la variable __d1 tant que valeur 32 bits et "1" (&__FDS_BITS (fdsp)[0]) lie le mĂȘme registre Ă  l'adresse fdsp->fds_bits tant que pointeur 64 bits. gcc et clang rĂ©solvent ce dĂ©calage. en utilisant le registre rdi 64 bits et en tronquant sa valeur avant d'affecter la valeur __d1 , et Rust utilise la sĂ©mantique LLVM par dĂ©faut, dans laquelle un tel cas reste indĂ©fini. Dans les versions de dĂ©bogage (pas dans les versions de version, qui se comportaient bien), nous avons vu que les deux opĂ©randes peuvent ĂȘtre affectĂ©s au registre edi , Ă  cause de quoi le pointeur est tronquĂ© Ă  32 bits avant le code assembleur intĂ©grĂ©, ce qui provoque des Ă©checs.

Étant donnĂ© que rustc transmet le code assembleur Rust intĂ©grĂ© Ă  LLVM avec trĂšs peu de modifications, nous avons dĂ©cidĂ© de corriger ce cas particulier dans C2Rust. Nous avons implĂ©mentĂ© une nouvelle caisse c2rust-asm-casts qui c2rust-asm-casts ce problĂšme grĂące au systĂšme de type Rust utilisant des fonctions de trait et d'assistance qui dĂ©veloppent et tronquent automatiquement les opĂ©randes liĂ©s Ă  une taille interne suffisamment grande pour contenir les deux opĂ©randes. Le code ci-dessus se traduit correctement comme suit:

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

Il convient de noter que ce code ne nécessite aucun type pour les valeurs d'entrée et de sortie dans l'assembly du code assembleur; lors de la résolution des conflits de types, comptez plutÎt sur eux pour générer les types Rust (principalement les types fresh6 et fresh8 ).

Variables globales alignées


La derniĂšre source de l'Ă©chec Ă©tait la variable globale suivante stockant 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 prend actuellement en charge l'attribut d'alignement pour les types de structure, mais pas pour les variables globales, c'est-à-dire éléments static . Nous avons envisagé des moyens de résoudre ce problÚme dans le cas général, soit dans Rust soit dans C2Rust, mais pour l'instant dans ioquake3, nous avons décidé de le corriger manuellement avec un court fichier de patch . Ce fichier correctif remplace l'équivalent ssemask Rust ssemask suit:

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

Exécution de quake3-rs


Lorsque cargo build --release , des fichiers binaires sont créés, mais ils sont créés sous target/release avec une structure de répertoires que le binaire ioquake3 ne reconnaßt pas. Nous avons écrit un script qui crée des liens symboliques dans le répertoire courant pour recréer la structure de répertoire correcte (y compris des liens vers des fichiers .pk3 contenant des ressources de jeu):

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

Le chemin /path/to/paks doit pointer vers le répertoire contenant les fichiers .pk3 .

Lançons maintenant le jeu! Nous devons passer +set vm_game 0 , etc., nous chargeons donc ces modules en tant que bibliothÚques partagées Rust, et non en tant qu'assemblage QVM, ainsi que cl_renderer pour utiliser le moteur de rendu OpenGL1.

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

Et ...


Nous avons lancé Quake3 sur Rust!


Voici une vidéo de la façon dont nous transposons Quake 3, téléchargeons le jeu et jouons-en un peu:


Vous pouvez Ă©tudier les sources transpilĂ©es dans la branche transpiled de notre rĂ©fĂ©rentiel. Il existe Ă©galement une branche refactored contenant les mĂȘmes sources avec plusieurs commandes de refactorisation prĂ©-appliquĂ©es.

Comment transposer


Si vous voulez essayer de transposer Quake 3 et l'exĂ©cuter vous-mĂȘme, notez que vous aurez besoin de vos propres ressources de jeu Quake 3 ou ressources de dĂ©monstration sur Internet. Vous devrez Ă©galement installer C2Rust (au moment de la rĂ©daction, la version nocturne requise est nightly-2019-12-05 , mais nous vous recommandons de consulter le rĂ©fĂ©rentiel C2Rust ou dans crates.io pour trouver la derniĂšre version):

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

et des copies de nos référentiels C2Rust et 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 

Au lieu d'installer c2rust à l'aide de la commande ci-dessus, vous pouvez créer C2Rust manuellement à l'aide de cargo build --release . Dans tous les cas, le référentiel C2Rust sera toujours nécessaire, car il contient les scripts d'encapsulation du compilateur nécessaires pour transposer ioquake3.

Nous avons publié un script qui transporte automatiquement le code C et applique le correctif ssemask . Pour l'utiliser, exécutez la commande suivante à partir du niveau supérieur du référentiel ioq3 :

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

Cette commande doit créer un sous quake3-rs répertoire quake3-rs contenant du code Rust, pour lequel vous pouvez ensuite exécuter la cargo build --release et les étapes restantes décrites ci-dessus.

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


All Articles