Programmation d'un mod pour QuakeIII

AttentionArticle en cours de rédaction.

Introduction

ID Software dans son infinie bonté nous livre les sources de Quake 3 Arena. Quake 3 serait-il alors une sorte de logiciel libre ? Pas vraiment.
Pas du tout même. Tout d'abord, il faut distinguer 2 choses : les sources du jeux lui-même (qui constituent le "gameplay", en définissent ses règles : déplacements, tirs, gestion des points, bref ce qui fait que QuakeIII est ce qu'il est) et les sources du moteur sur lequel il est bâti (rendu graphique (OpenGL), connexions réseaux (UDP), chargement des fichiers pk3 (format ZIP), ...).
Le premier utilise les services du second, un peu comme un programme utilise les services offerts par un système d'exploitation. C'est uniquement les sources du jeu que nous avons, et absolument pas celles du moteur 3D. Vous avez le droit de modifier ces sources mais il vous est interdit de vendre quoi que ce soit. Donc en clair, si ID Software fournit ces sources, c'est pour que tout plein de gentils programmeurs développent gratuitement des mods pour Quake3.
On comprend mieux pourquoi IDS est si généreux, mais cela fait tout de même notre bonheur.

Organisation du code source

Allez sur le site d'ID Software, récupérez les dernières sources du jeu, et installez-les. A l'heure ou j'écris ces lignes, il s'agit de la version 1.29f, la version du moteur étant la 1.32.
Les sources sont composées de pas moins de 150 fichiers, dont plusieurs dépassent les 5000 lignes de code (Ca laisse songeur sur ce que doivent être celles du moteur 3D ...).
Vous trouverez aussi des fichiers projets pour Visual C++. Seul cet IDE est supporté, et si vous voulez utiliser un autre outil vous devrez vous débrouiller tout seul.
Allez dans le répertoire d'installation. Vous constatez la présence de 3 répertoires : Le répertoire qui nous intéresse est le répertoire code, qui en contient 4 autres : cgame, game, q3_ui et ui. Le code est en effet décomposé en 3 modules : Le module le plus volumineux est naturellement le serveur, puis le client et enfin l'interface. Il ne faut pas oublier qu'il y a une quatrième partie : le moteur 3D (l'exécutable de Quake 3 en fait).
Mais avant d'aller plus loin, il me faut vous dire un mot sur les fichiers qvm.

La Quake Virtual Machine (QVM)

Avez-vous déjà ouvert un fichier pk3 de QuakeIII ? Ces fichiers ne sont en fait que de simples fichiers compressés au format ZIP. Ouvrez en un et regardez. Vous aller trouver une multitude de fichiers différents :
et d'autres mystérieux fichiers : les qvm.
QVM est l'acronyme de Quake Virtual Machine, car ces fichiers contiennent du bytecode qui peut être soit compilé "just in time" (juste à temps) en code machine natif soit interprété lors de l'exécution par la machine virtuelle de QuakeIII !
Par défaut, les qvm sont compilés "juste à temps". Ceci est paramétré à l'aide des cvars vm_game, vm_cgame, vm_ui. Pour obtenir des infos sur les qvm chargés, utilisez la commande vminfo.
mode par défaut : compilation "on load"
]\vminfo
Registered virtual machines:
qagame : compiled on load
    code length : 1137840
    table length:  586688
    data length : 2097152
ui : compiled on load
    code length :  594408
    table length:  319976
    data length : 1048576
cgame : compiled on load
    code length :  786818
    table length:  399612
    data length : 4194304
Maintenant changez le mode de chargement de chaque qvm en modifiant les cvars suivantes :
]\vm_game 1
]\vm_ui 1
]\vm_cgame 1
mode interprétation :
]\vminfo
Registered virtual machines:
qagame : interpreted
    code length :  436836
    table length:  586688
    data length : 2097152
ui : interpreted
    code length :  244976
    table length:  319976
    data length : 1048576
cgame : interpreted
    code length :  294824
    table length:  399612
    data length : 4194304
Voici une trace d'exécution ou le changement de mode de chargement de chaque qvm est suivi d'un test de performances. Ce test consiste à jouer la démo four.dm_68 (fournie avec les derniers patchs de QuakeIII) en ayant positionné la variable timedemo à 1, ce qui accélère sa lecture et affiche un compte rendu.
]\timedemo 1
]\vm_game 0
]\vm_ui 0
]\vm_cgame 0
]\demo four
1260 frames, 16.7 seconds: 75.3 fps
]\vm_ui 1
]\demo four
1260 frames, 16.6 seconds: 75.4 fps
]\vm_game 1
]\demo four
1260 frames, 16.7 seconds: 75.3 fps
]\vm_cgame 1
]\demo four
1260 frames, 23.8 seconds: 53.0 fps
L'interprétation est donc sensiblement plus coûteuse que la compilation juste à temps.

QVM ou DLL ?

Lorsque vous créez un mod, vous avez le choix entre 2 possibilités : A première vue, la première solution est préférable ... mais elle ne fonctionne que sous Windows ! Pour jouer à votre mod, les joueurs devront obligatoirement posséder un PC sous Windows. Ce n'est pas le cas avec les qvm : quelque soient l'OS et le processeur, ca marchera ! Cela veut dire que si vous créer un serveur sous Windows pour votre mod compilé en qvm, un joueur sous Mac pourra se connecter, télécharger le qvm via l'AutoDownloading et jouer directement ! Impressionnant n'est-ce pas ?
Il est donc compréhensible qu'ID Software recommande d'utiliser les qvm. Les dll sont toutefois justifiées lors du développement du mod, car elles permettent d'effectuer du débogage et sont plus simples à compiler. En revanche elles ne permettent pas de créer un serveur en mode "pure".
Le revers de la médaille est qu'un qvm ne peut pas faire appel aux fonctions d'un OS pour cause de portabilité. Cela veut dire que votre code ne doit utiliser aucune librairie externe, même pas la librairie standard ! Les seules fonctions externes que votre code peut appeler sont celles du moteur 3D. Si vous avez besoins d'une autre fonction que celles disponibles, vous devez l'écrire.
Id Software a ainsi ré-écrit certaines fonctions standards (manipulation de chaînes de caractères, fonctions mathématiques, ...). Elles se trouvent dans bg_lib.c et sont déclarées dans bg_lib.h.
bg_lib.h n'est utilisé que lors de la compilation d'un qvm. Autrement, les fonctions de la librairie standard conviennent parfaitement. Un commentaire dans bg_lib.h le rappelle :
// bg_lib.h -- standard C library replacement routines used by code
// compiled for the virtual machine

// This file is NOT included on native builds
Le fichier principal à inclure est q_shared.h. C'est un des rares fichiers à prendre en compte la plateforme cible, ceci afin d'effectuer quelques adaptations. C'est lui qui inclut bg_lib.h dans le cas d'un qvm et inclut les bibliothèques standards le cas échéant :
#ifdef Q3_VM
#include "bg_lib.h"
#else
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <ctype.h>
#include <limits.h>
#endif
Donc si l'envie vous prend d'inclure un fichier quelconque, c'est ici qu'il faut le faire. A vous ensuite d'implémenter les fonctions utilisées si vous voulez compiler en qvm.
Nous avons donc vu comment était organisé un mod et comment celui-ci était lié au moteur. Mais une question se pose : comment fait-on, depuis le code source, pour communiquer avec ce dernier et faire appel à ses services ?

Interaction avec le moteur 3D

Le moteur met à notre disposition tout un ensemble de fonctions. Ce sont en quelque sorte des fonctions systèmes et sont reconnaissables à leur nom qui commence par trap_.
C'est une analogie avec les systèmes d'exploitation où les fonctions systèmes d'un OS sont appelées au moyen de trappes (cela dépend des systèmes ...).
Un appel au moteur est donc effectué au moyen d'une trappe. La trappe en question est une fonction : syscall, qui permet d'effectuer un "appel système".
Ainsi, toutes les fonctions systèmes sont implémentées suivant ce schéma :
void	trap_Print( const char *fmt ) {
	syscall( CG_PRINT, fmt );
}

int		trap_Milliseconds( void ) {
	return syscall( CG_MILLISECONDS );
}
Chaque fonction système est identifiée par un numéro, et elle est appelée à l'aide de syscall(N°_Fonction, arguments ...).
Allez donc jeter un oeil dans game\g_syscalls.c, cgame\cg_syscalls.c, ui\ui_syscalls.c pour plus d'informations.
Donc, pour appeler une fonction du moteur, chaque partie effectue en fait un simple appel à syscall qui constitue la trappe. Mais comment chacune des ces parties fait-elle pour accéder à cette trappe ?
Dans le cas des dll, les premières lignes de chaque fichier source syscalls.c nous donne la solution :
// cg_syscalls.c -- this file is only included when building a dll
// cg_syscalls.asm is included instead when building a qvm

static int (QDECL *syscall)( int arg, ... ) = (int (QDECL *)( int, ...))-1;


void dllEntry( int (QDECL  *syscallptr)( int arg,... ) ) {
	syscall = syscallptr;
}
syscall est en fait un pointeur sur une fonction initialisé lors du chargement de la dll par le moteur 3D. dllEntry est alors appelée (elle constitue le point d'entrée de chaque dll) avec en paramètre un pointeur valide sur syscall.
C'est donc par cette trappe que le moteur 3D met à notre disposition une API complète nous permettant de piloter le moteur 3D tout en se passant d'appels au système d'exploitation.
C'est par exemple via cette API que s'effectue la gestion de fichiers, à l'aide des fonctions trap_FS_FOpenFile, trap_FS_Read, trap_FS_Write, et trap_FS_FCloseFile.
Voyons maintenant comment utiliser cette API.

Fonctionnement du moteur 3D

Les entités

Le terme de moteur n'est pas choisi au hasard. On peut réellement voir cela comme un moteur qui fonctionne tout seul et que l'on peut piloter.
Ce moteur manipule des entités (entity), et agit en fonction de celles-ci. Le but du code source de notre mod est de lui fournir des entités à traiter, chose qu'il fait sans relâche.
Prenez une partie de Quake 3, enlevez la carte au sens strict (les murs, les décors, les jumpers, les portes ?...) et il ne reste que les entités :
Les entités peuvent se déplacer dans la carte, entrer en collision avec la carte ou une autre entité. La direction, la vitesse de déplacement ainsi que le comportement en cas de collision sont propres à chaque entité et déterminés par la partie serveur.
Le calcul de la prochaine position de chaque entité en fonction ainsi que la détection de collision sont effectués par le moteur 3D.
On saisit donc mieux le sens de moteur : on lui fournit les paramètres de départ (roquette tirée dans telle direction depuis telle origine avec telle vitesse) et le moteur se charge de faire évoluer tout cela. Il recalcule et met à jour toutes les caractéristiques de chaque entité à chaque calcul d'une image (frame).

Comment fait-on pour dire au moteur de gérer une nouvelle entité ?

Le moteur manipule en fait un tableau d'entité. Une entité est une structure complexe ayant entre autre en champ empty permettant de savoir si elle est utilisée ou non. Donc, pour ajouter une nouvelle entité, il suffit de rechercher une entité libre dans le tableau d'entité, de la remplir correctement et de la marquer comme utilisée. Dès lors, le moteur la prendra en considération.

Comment le moteur fait-il pour nous avertir de quelque chose ?

Une entité comporte aussi dans ses champs une liste de pointeurs de fonctions associées à un évènement particulier. Il s'agit en fait de fonctions callback (appelées par le moteur et non par vous). C'est donc de cette manière que le moteur vous permet de réagir face à tel ou tel évènement. En cas de collision par exemple, le moteur exécutera le code de la fonction callback correspondante.
On peut aussi générer une sorte d'évènement timer, une entité permettant de provoquer l'appel d'une fonction à un instant précis. Ceci a beaucoup d'applications :
En utilisant de manière intelligente les entités, on peut donc arriver en quelque sorte à automatiser le jeu.
Mais il y a des évènements que l'on ne peut pas gérer de cette manière, tout simplement parce qu'ils ne concernent pas une entité. C'est le cas des sons. Un son n'est pas une entité. Une roquette qui explose, c'est une entité qui disparaît, mais le son qu'elle produit n'est pas un élément de la carte.
Pour gérer de tels éléments on procède tout bêtement au moyens évènements. Le moteur n'est d'ailleurs pas forcément le seul émetteur d'évènements. Dans le cas de l'explosion d'une roquette, c'est le serveur qui envoie à chaque client l'évènement "une roquette a explosé" (à condition que celui-ci soit assez près pour entendre l'explosion).
Un autre type d'interaction particulière qu'il y a à gérer concerne le joueur, c'est à dire traiter ses actions du jeu (mouvements de souris, actions au clavier, ...) mais aussi ses commandes (qu'il tape directement dans la console ou indirectement via l'interface).

La fonction vmMain

Tout programme C standard comporte une fonction main. C'est aussi le cas avec les modules de QuakeIII, à ceci près qu'elle s'appelle vmMain (g_main.c, cg_main.c, ui_main.c).
Son prototype est très différent de la fonction main standard :
int vmMain( int command,
            int arg0,
            int arg1,
            int arg2,
            int arg3,
            int arg4,
            int arg5,
            int arg6,
            int arg7,
            int arg8,
            int arg9,
            int arg10,
            int arg11 );
Son principe est aussi très différent. Une fonction main standard est appelée en tout début d'exécution, puis quand on quitte cette fonction, le programme se termine.
vmMain fonctionne différemment. vmMain est appelée des dizaines de fois par seconde lors d'une partie de Quake3, à chaque fois pour effectuer une tâche bien précise.
Le premier argument de wmMain (command) contient la commande à traiter (init, shutdown, ...). Les arguments qui suivent ont une signification spécifique à chaque commande. La plupart du temps, seuls arg0, arg1 et arg2 sont utilisés.
Un schéma simpliste d'appels à wmMain est le suivant :
Chaque partie étant différente, nous allons les étudier séparément. Commençons par la partie serveur.

Serveur

Le serveur s'occupe des clients : il calcule sans cesse leur nouvelle position en prenant en compte leurs mouvements et les informe du résultat. C'est le seul a connaître précisément la position de tout le monde à un moment donné.
En effet, pour remédier à une mauvaise connexion et donc un faible nombre d'information leur parvenant quant à leur position, les clients font de la prédiction. Ils exécutent en fait le même code que le serveur pour savoir avant même qu'il ne leur ait dit où ils vont être. Ceci rend le jeu bien plus fluide pour le joueur. En contre partie, cela reste de la prédiction, et n'est donc pas la réalité.
C'est pour cela que parfois, surtout si vous avez joué avec une très mauvaise connexion (un modem) vous passez sur une armure, vous entendez le bruit comme quoi vous l'avez bien prise, vous voyez votre niveau d'armure augmenter, et subitement tout disparaît. C'est parce que le module client a fait une mauvaise prédiction (un autre joueur a coté de vous était un peu plus proche que vous de l'armure).
Ayez donc bien ceci à l'esprit : tous les mouvements, de la rotation d'une arme à terre aux boyaux qui s'éparpillent en passant bien sûr par un joueur sont calculés par le module serveur.
Le serveur s'occupe donc :
Pour aider à s'y retrouver dans les 64 fichiers sources de la partie serveur, voici la signification de quelques acronymes :
Comme chaque module, le serveur s'occupe aussi des commandes qui lui sont destinées (kick, map_restart, fraglimit, ...).

Nous allons donc commencer notre analyse à partir de wmMain (g_main.c).
/*
================
vmMain

This is the only way control passes into the module.
This must be the very first function compiled into the .q3vm file
================
*/
int vmMain( int command, int arg0, int arg1, int arg2, int arg3, int arg4, int arg5,
            int arg6, int arg7, int arg8, int arg9, int arg10, int arg11  ) {
	switch ( command ) {
	case GAME_INIT:
		G_InitGame( arg0, arg1, arg2 );
		return 0;
	case GAME_SHUTDOWN:
		G_ShutdownGame( arg0 );
		return 0;
	case GAME_CLIENT_CONNECT:
		return (int)ClientConnect( arg0, arg1, arg2 );
	case GAME_CLIENT_THINK:
		ClientThink( arg0 );
		return 0;
	case GAME_CLIENT_USERINFO_CHANGED:
		ClientUserinfoChanged( arg0 );
		return 0;
	case GAME_CLIENT_DISCONNECT:
		ClientDisconnect( arg0 );
		return 0;
	case GAME_CLIENT_BEGIN:
		ClientBegin( arg0 );
		return 0;
	case GAME_CLIENT_COMMAND:
		ClientCommand( arg0 );
		return 0;
	case GAME_RUN_FRAME:
		G_RunFrame( arg0 );
		return 0;
	case GAME_CONSOLE_COMMAND:
		return ConsoleCommand();
	case BOTAI_START_FRAME:
		return BotAIStartFrame( arg0 );
	}

	return -1;
}
En surlignant GAME_INIT (sous VC++) et faisant un clic droit->Go To Definition Of GAME_INIT, on se retrouve dans g_public.h :
//
// functions exported by the game subsystem
//
typedef enum {
	GAME_INIT,	// ( int levelTime, int randomSeed, int restart );
	// init and shutdown will be called every single level
	// The game should call G_GET_ENTITY_TOKEN to parse through all the
	// entity configuration text and spawn gentities.

	GAME_SHUTDOWN,	// (void);

	GAME_CLIENT_CONNECT,	// ( int clientNum, qboolean firstTime, qboolean isBot );
	// return NULL if the client is allowed to connect, otherwise return
	// a text string with the reason for denial

	GAME_CLIENT_BEGIN,				// ( int clientNum );

	GAME_CLIENT_USERINFO_CHANGED,	// ( int clientNum );

	GAME_CLIENT_DISCONNECT,			// ( int clientNum );

	GAME_CLIENT_COMMAND,			// ( int clientNum );

	GAME_CLIENT_THINK,				// ( int clientNum );

	GAME_RUN_FRAME,					// ( int levelTime );

	GAME_CONSOLE_COMMAND,			// ( void );
	// ConsoleCommand will be called when a command has been issued
	// that is not recognized as a builtin function.
	// The game can issue trap_argc() / trap_argv() commands to get the command
	// and parameters.  Return qfalse if the game doesn't recognize it as a command.

	BOTAI_START_FRAME				// ( int time );
} gameExport_t;
Les commentaires nous donnent plus d'information sur les différents paramètres de chaque commande.
En faisant de même pour toutes les fonctions appelées lors du traitement de ces commandes, on peut avoir une bonne idée du rôle de chacune d'entre elle :
case GAME_INIT:
      G_InitGame( arg0, arg1, arg2 );
      return 0;
Initialise la partie.
// init and shutdown will be called every single level
// void G_InitGame( int levelTime, int randomSeed, int restart )
case GAME_SHUTDOWN:
      G_ShutdownGame( arg0 );
      return 0;
Arrete la partie.

A suivre ...


Sommaire