Catégorie : Programmation     Tags :      

    Retrouvez cet article dans : Linux Magazine 84

    On voit souvent sur les forums des personnes se demander si on peut « packager » les ressources nécessaires à un programme à l’intérieur de celui-ci. Oui, on peut, et je vais vous montrer par l’exemple comment le faire et surtout comment réutiliser ces ressources dans un programme SDL, grâce aux SDL_RWOps.

    Fichier de ressources ?

    On peut imaginer un fichier de ressources comme une archive tar. Plusieurs fichiers sont stockés dans un seul et on a un moyen d’accéder individuellement à chacun.
    On va commencer par valider un point de détail. Est-il possible de stocker des données dans un programme ?

    $ cp `which ls` ./
    $ ./ls /
    bin   dev   initrd         lib
    mnt   root  sys            var
    boot  etc   initrd.img     lost+found
    opt   sbin  tmp            vmlinuz
    cdrom home  initrd.img.old media
    proc  srv   usr            vmlinuz.old
    
    $ cat /etc/passwd >> ./ls
    
    $ ./ls /
    bin   dev   initrd         lib
    mnt   root  sys            var
    boot  etc   initrd.img     lost+found
    opt   sbin  tmp            vmlinuz
    cdrom home  initrd.img.old media
    proc  srv   usr            vmlinuz.old

    On se crée une copie locale du programme ls. On vérifie qu’il fonctionne. On utilise la commande cat pour y ajouter le contenu du fichier /etc/passwd. ls fonctionne toujours. Vous pouvez retrouver le contenu du fichier passwd via cat ./ls ou hexdump -C ./ls.

    Habituellement, les ressources sont constituées d’un en-tête. On peut ainsi y trouver les noms et tailles de ressources et éventuellement l’endroit dans le fichier où elles sont stockées, comme dans la table d’allocation de fichiers (FAT) sur une partition de disque dur.

    Le problème que l’on se pose ici est de stocker des ressources dans l’exécutable. Cette logique ne peut donc pas être gardée. En effet, on ne peut rien écrire en tête de l’exécutable. On doit donc inverser le raisonnement.

    Plutôt qu’un en-tête, on va utiliser une « queue de fichier » qui aura la forme suivante :

     

    /img-articles/lm/84/art-8/t1.jpg

    Tout le début du fichier sera l’exécutable lui-même.
    Les ressources sont placées à la suite et doivent donc être lues depuis la fin. Pour cette raison, on « signe » le fichier, afin de faire la différence entre un fichier comprenant des ressources, de l’exécutable seul. On utilise une simple chaîne de caractères pour signifier la présence de ressources, ici SDLALLINONE suivi d’un zéro terminal.
    Après avoir vérifié la présence de cette signature, on peut « remonter » dans le fichier. Juste avant la signature se trouve le nombre de couples représentant une ressource. Ce nombre est codé sur 4 octets, le sens big endian/little endian étant ignoré.
    Un couple représentant une donnée est constitué des données brutes et est suivi par la taille en octets occupée par ces données. Cette taille est, elle aussi, codée sur 4 octets. Dans les faits, une donnée sera stockée sur 2 couples, le premier couple de données servant au stockage du nom de la donnée, et le second à son contenu.
    Grâce à cette structure, on peut accéder à n’importe laquelle des données quelle que soit la taille de l’exécutable en introduction.

    Packager : dopack

    Maintenant que la structure est connue, on peut s’attaquer à la création de l’outil qui réalisera ces packs.
    J’ai choisi l’utilisation suivante pour l’outil qu’on appellera dopack :

    USAGE: dopack executable [res_top_dir]

    On passe à dopack le nom d’un exécutable à la suite duquel ajouter les ressources contenues dans un répertoire passé en second argument. Si ce dernier est omis, on ajoutera le contenu du répertoire nommé res.

    Structure

    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <dirent.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <unistd.h>
    #include <limits.h>
    
    const char CHECK_SUFFIX[] = “SDLALLINONE”;
    const long l_size = sizeof(long);
    const long suffix_len = sizeof(CHECK_SUFFIX);

    On commence par charger les fichiers d’en-tête nécessaires et on définit trois constantes, pour la signature des fichiers, pour stocker la taille d’un long et la taille de la signature.

     int main (int argc, char** argv) {
      int packfd;
      long nb_resources = 0;
      long nb_couples = 0;
      char* pack;
      char* topdir = «res»;
    
      if (argc < 2 || argc > 3) {
        fprintf (stderr,
          «USAGE: %s executable [res_top_dir]\n»,
          argv[0]);
        fprintf (stderr,
          „       res_top_dir defautls to res\n“);
        return 1;
      }
    
      pack = argv[1];
      if (argc == 3) {
        topdir = argv[2];
      }

    On déclare quelques variables. Le descripteur de fichier du pack. Le nombre de ressources et le nombre de couples contenus dans le pack, le nom de l’exécutable et le nom du répertoire contenant les ressources, fixé à res par défaut, comme vu plus haut.
    On vérifie ensuite le nombre d’arguments. S’il est faux, on affiche le message habituel d’usage et on quitte. Une fois les préliminaires passés, on fixe les noms des pack et répertoire de données.

     int main (int argc, char** argv) {
      int packfd;
      long nb_resources = 0;
      long nb_couples = 0;
      char* pack;
      char* topdir = «res»;
    
      if (argc < 2 || argc > 3) {
        fprintf (stderr,
          «USAGE: %s executable [res_top_dir]\n»,
          argv[0]);
        fprintf (stderr,
          „       res_top_dir defautls to res\n“);
        return 1;
      }
    
      pack = argv[1];
      if (argc == 3) {
        topdir = argv[2];
      }

    On prévoit un message informatif prévenant du début de la création du pack. On ouvre l’exécutable et on se place en fin de fichier. La fonction append_res, sur laquelle je reviendrai plus tard, se charge d’ajouter les ressources au fichier. Notez toutefois qu’elle retourne le nombre de fichiers ajoutés à la fin de l’exécutable. Ce nombre doit être multiplié par deux pour obtenir le nombre de ressources, pour les raisons citées plus haut.

    • On affiche le nombre de fichiers ajoutés pour contrôle et on l’ajoute également à l’exécutable.
    • On « signe » enfin le « pack » en y inscrivant le suffixe.
    • On va à présent s’intéresser au code faisant le gros du travail, l’ajout des ressources proprement dit.

    Ce dernier doit être décomposé en deux parties, la première se chargeant du parcours de l’arborescence passée en paramètre et la seconde de l’ajout des ressources trouvées, ce faisant. Tous les fichiers trouvés seront ainsi ajoutés à l’exécutable.

    append_res

    int append_res (int packfd, const char *path, long* nb_res) {
      DIR *rep;
      struct dirent *entry;
      struct stat s;
      char* tmp_path = NULL;
      int isOK = 1;
      if ((rep = opendir(path))) {
        while ((entry = readdir(rep))) {
          tmp_path = (char*)realloc (tmp_path,
            strlen(path)+strlen(entry->d_name)+2);
          strcpy (tmp_path, path);
          strcat (tmp_path, “/”);
          strcat (tmp_path, entry->d_name);
          if (!strcmp (entry->d_name, “.”)) {
            continue;
          }
          if (!strcmp (entry->d_name, “..”)) {
            continue;
          }
          if (-1 == stat (tmp_path, &s)) {
    
            fprintf (stderr, “Can’t stat %s\n”, tmp_path);
            isOK = 0;
            break;
          }
          if (S_ISDIR(s.st_mode)) {
            append_res (packfd, tmp_path, nb_res);
          } else if (S_ISREG(s.st_mode)) {
            if (append_file (packfd, tmp_path)) {
              ++(*nb_res);
            } else {
              fprintf (stderr, “Can’t append %s\n”, tmp_path);
              isOK = 0;
              break;
            }
          }
        }
        closedir(rep);
        free (tmp_path);
      } else {
        fprintf (stderr, “Can’t open %s\n”, path);
        isOK = 0;
      }
      return isOK;
    }

    Le code de la fonction append_res est extrêmement simple. Il s’agit d’un bête parcours de répertoires basé sur les fonctions opendir et readir faisant appel à la récursivité.

    • On supprime les entrées qui ne nous intéressent pas («.» et «..»).
    • Si on rencontre un répertoire (S_ISDIR), on relance une exploration.
    • Si on rencontre un fichier (S_ISREG), on appelle la fonction append_file qui l’ajoute au « pack ».

    L’originalité de ce parcours réside dans le stockage du chemin en cours de vérification. En effet, il devra être stocké, pour différencier deux fichiers de même nom stockés dans des répertoires différents et pour faciliter le portage d’applications SDL désirant utiliser ce format, comme on le verra dans la deuxième partie de l’article.

    append_file

    Il ne nous reste plus qu’un morceau du mécanisme de génération des packs. L’ajout de ressources proprement dit. Ce travail est pris en charge par la fonction append_file, dont voici le code :

    int append_file (int packfd, const char *path) {
      int isOK = 1;
      int datafd;
      char buffer[4096];
      long bufferlen;
      long taille;
    
      bufferlen = strlen(path);
      write (packfd, path, bufferlen);
      write (packfd, &bufferlen, l_size);
      printf (“Ajout de %s\n”, path);
    
      taille = 0;
      datafd = open (path, O_RDONLY);
      if (-1 == datafd) {
        printf («Attention, erreur sur %s\n», path);
        write (packfd, &taille, l_size);
        isOK = 0;
      }
      do {
        bufferlen = read (datafd, buffer, sizeof(buffer));
        if (bufferlen > 0)
          taille += write (packfd, buffer, bufferlen);
        else
          break;
      } while (1);
    
      write (packfd, &taille, l_size);
      close (datafd);
    
      return isOK;
    }

    append_file reçoit en arguments le descripteur de fichier du pack ainsi que le chemin de la nouvelle ressource à y ajouter.
    On déclare les variables nécessaires au fonctionnement, un descripteur de fichier pour la ressource et un buffer pour les lectures et écritures. On commence par stocker dans le pack le nom de la ressource et sa longueur. On affiche un message d’avancement spécifiant la ressource en cours d’ajout.
    Ensuite, on copie le contenu de la ressource à la suite du pack dans une simple boucle read/write.
    On termine l’ajout en écrivant à la suite des données, la taille qu’elles occupent, en octets. Pour compiler, on utilise gcc -Wall -O2 -o dopack dopack.c.

    Utiliser : Application à la SDL

    Pour comprendre la suite, une courte présentation d’un aspect méconnu de la SDL, les SDL_RWOps, s’impose.

    Introduction aux SDL_RWOps

    La bibliothèque SDL (et ses associées SDL_Mixer et SDL_Image) permettent de charger les ressources d’un programme via des structures appelées SDL_RWOps.
    Les SDL_RWOps représentent, comme leur nom l’indique, des opérations de lecture et écriture (mais aussi déplacement et nettoyage). Ces opérations peuvent se faire sur des fichiers et de la mémoire grâce aux RWOps existantes ou bien sur tout ce que vous voulez d’autre, par exemple un fichier bz2 ou un fichier chiffré par vos soins.
    On peut créer de nouvelles SDL_RWOps. Il suffit pour cela de fournir 4 fonctions, adaptées au format que vous aurez choisi, implémentant les fonctionnalités nécessaires à la SDL.

    typedef struct SDL_RWOps {
      int (SDLCALL *seek)(struct SDL_RWOps *context,
        int offset, int whence);
      int (SDLCALL *read)(struct SDL_RWOps *context,
        void *ptr, int size, int maxnum);
      int (SDLCALL *write)(struct SDL_RWOps *context,
        const void *ptr, int size, int num);
      int (SDLCALL *close)(struct SDL_RWOps *context);
    
      Uint32 type;
        union {
          struct {
            int autoclose;
            FILE *fp;
          } stdio;
          struct {
            Uint8 *base;
            Uint8 *here;
            Uint8 *stop;
          } mem;
          struct {
            void *data1;
          } unknown;
        } hidden;
    } SDL_RWOps;

    Un type et une zone de données sont également prévus dans la structure SDL_RWOps, pour stocker les données internes. C’est nécessaire, car seul le pointeur vers la SDL_RWOps sera passé à vos fonctions.

    Vous devez également fournir une fonction permettant la construction d’un de vos SDL_RWOps. A titre d’exemple, voici ceux offerts par la SDL permettant la création depuis un chemin, un fichier ou une zone de mémoire :

    extern DECLSPEC SDL_RWOps * SDLCALL
      SDL_RWFromFile(const char *file, const char *mode);
    extern DECLSPEC SDL_RWOps * SDLCALL
      SDL_RWFromFP(FILE *fp, int autoclose);
    extern DECLSPEC SDL_RWOps * SDLCALL
      SDL_RWFromMem(void *mem, int size);
    extern DECLSPEC SDL_RWOps * SDLCALL
      SDL_RWFromConstMem(const void *mem, int size);

    Ces considérations, bien qu’intéressantes, ne seront pas toutes utiles ici. Elles permettent néanmoins d’appréhender ce que sont les SDL_RWOps et vous donner envie d’aller plus loin. On va, pour cet exemple, charger les données en mémoire et utiliser la fonction SDL_RWFromMem pour créer notre SDL_RWOps.

    Chargement de la ressource en mémoire

    Le plus délicat avec le format choisi consiste à retrouver les ressources dans le pack et à en charger le contenu en mémoire. Voyons ensemble le code se chargeant de cette tâche.

    SDL_RWOps *SAIO_GetResource (const char* resFile, const char* resPath, char** donnees) {

    La fonction SAIO_GetResource se chargera d’ouvrir un fichier resFile, pour y chercher une ressource dont le chemin est resPath. Elle retournera un pointeur sur une structure SDL_RWOps ainsi qu’une zone de mémoire, qu’elle allouera et contenant les données de la ressource.
    Le fait de spécifier le fichier permet de faciliter le passage de plusieurs fichiers de ressources.

      int us_fd;
      long taille_donnees = 0;
      long nb_couples;
      long i;
      long taille_nom = 0;
      long taille = 0;
      char* nom;
      char buffer[suffix_len];
    
      us_fd = open (resFile, O_RDONLY);
      if (-1 == us_fd) {
        return NULL;
      }

    On déclare un descripteur de fichier pour ouvrir le pack, c’est-à-dire l’exécutable lui-même dans cet exemple et on s’auto-ouvre en lecture seule.

       lseek (us_fd, -suffix_len, SEEK_END);
      read (us_fd, buffer, suffix_len);
      if (0 != strcmp(buffer, CHECK_SUFFIX)) {
        close (us_fd);
        fprintf (stderr, “Attention: %s n’est pas “
          “une appli SAIO\n”, resFile);
        return NULL;
      }
      lseek (us_fd, -suffix_len, SEEK_CUR);

    On se place à la fin du fichier moins la longueur du suffixe. On lit le nombre de caractères correspondant à la taille du suffixe pour vérifier qu’il s’agit bien d’un exécutable contenant ses ressources. En cas d’erreur, on se referme, on affiche un message d’erreur et on retourne NULL.
    Si la lecture du suffixe s’est bien passée, on recule d’autant de caractères pour se replacer au-dessus de lui.

      lseek (us_fd, -l_size, SEEK_CUR);
      read (us_fd, &nb_couples, l_size);
      if (0 == nb_couples) {
        fprintf (stderr, “Attention: %s ne contient “
          “pas de resources\n”, resFile);
        return NULL;
      }
      lseek (us_fd, -l_size, SEEK_CUR);

    On tente ensuite de lire le nombre de ressources qui se trouvent dans ce pack. Le principe de lecture est toujours le même, on remonte du nombre d’octets correspondant à la taille de la donnée que l’on souhaite lire, on la vérifie et on remonte d’autant d’octets que la lecture nous a fait avancer si le test s’est avéré bon.

      i = nb_couples-1;
      nom = NULL;
      *donnees = NULL;
      do {
        lseek (us_fd, -l_size, SEEK_CUR);
        read (us_fd, &taille, l_size);
        lseek (us_fd, -l_size, SEEK_CUR);
        if (i%2) { //données
          taille_donnees = taille;
        } else { // nom
          taille_nom = taille;
          lseek (us_fd, -taille, SEEK_CUR);
          nom = (char*)realloc (nom, taille+1);
          if (nom) {
            memset (nom, 0, taille+1);
            read (us_fd, nom, taille);
            if (!strcmp (resPath, nom)) {
              lseek (us_fd, l_size, SEEK_CUR);
              *donnees = (char*)malloc (taille_donnees);
              if (*donnees) {
                read (us_fd, *donnees, taille_donnees);
              }
              break;
            }
          }
        }
        lseek (us_fd, -taille, SEEK_CUR);
      } while (i--);
    
      free (nom);

    On remonte ensuite dans les ressources, tant qu’il en reste ou jusqu’à ce qu’on ait trouvé celle correspondant au chemin recherché.
    A chaque fois, on commence par lire la taille de la ressource.
    S’il s’agit de données, on stocke la taille pour l’utiliser éventuellement au tour suivant.
    S’il s’agit d’un nom de ressource, on le compare à celui de la ressource recherchée. S’ils correspondent, on charge la ressource en mémoire, après avoir alloué une zone de la taille correspondante.

      if (!*donnees) {
        fprintf (stderr, «Attention: %s ne contient pas %s\n»,
            resFile, resPath);
        return NULL;
      }
    
      close (us_fd);
    
      return SDL_RWFromMem(*donnees, taille_donnees);
    }

    A la fin de la boucle, soit on n’a pas trouvé la ressource recherchée et la variable donnees est nulle, soit on l’a trouvée, auquel cas son contenu est stocké dans donnees et sa taille dans taille_donnees.
    Si tout s’est bien déroulé, on peut construire et retourner une structure SDL_RWOps, grâce à la fonction SDL_RWFromMem qui prend en paramètres une zone de données et sa taille en octets.
    Cette fonction est simple, mais son fonctionnement pourrait être amélioré en gardant le fichier ouvert par exemple ou en faisant une table d’index une fois pour toutes lors du premier appel. Ceci pourra faire l’objet d’améliorations que je vous laisse en « exercice » pendant ou après la lecture.
    Maintenant que nous avons les briques essentielles, attaquons-nous aux détails.

    Images

    IMG_Load_RW est la fonction faisant le travail de l’habituelle IMG_Load, mais en utilisant la structure SDL_RWOps passée en paramètre. Elle est déclarée dans le fichier d’en-tête SDL_image.h.

     extern DECLSPEC SDL_Surface * SDLCALL IMG_Load_RW(SDL_RWops *src, int freesrc);

    Le second paramètre spécifie si la structure SDL_RWOps doit être libérée après le chargement de l’image. Répondez « oui », si vous ne chargez l’image qu’une seule fois. Sinon, vous devrez le faire vous-même.

    On va écrire un wrapper pour cette fonction, afin de faciliter la réécriture des applications SDL.

    SDL_Surface* SAIO_IMG_Load (const char* resFile,
            const char* resPath) {
      SDL_Surface* sdl_surf = NULL;
      SDL_RWops *rw;
      char* donnees = NULL;
    
      rw = SAIO_GetResource (resFile, resPath, &donnees);
      if (!rw) {
        // On essaye à l’ancienne
        return IMG_Load (resPath);
      }
      sdl_surf = IMG_Load_RW(rw, 1);
      free (donnees);
    
      return sdl_surf;
    }

    SAIO_IMG_Load accepte deux paramètres. Le premier est le chemin vers un fichier de ressources et le second le chemin vers la ressource.

    Cette fonction appelle celle définie dans la partie précédente SAIO_GetResource. En cas d’échec de cette dernière, on fait appel à la fonction classique IMG_Load en lui passant le second argument, ce qui permet de pouvoir fonctionner de manière transparente en version packée ou non.

    On note le 1 passé en second paramètre à IMG_Load_RW pour libérer la structure SDL_RWOps après le chargement de l’image. Remarquez que l’on doit nous-mêmes libérer la mémoire. C’est normal, puisque c’est nous qui l’avions allouée. La création du SDL_RWOps ne copie pas cette mémoire, il n’y a qu’un pointeur stocké dans la structure. C’est donc à nous de la libérer, mais, surtout, on ne doit pas le faire tant qu’on est amené à se servir du SDL_RWOps l’utilisant.

    Il suffit ensuite dans le code de remplacer tous les appels à IMG_Load par des appels à SAIO_IMG_Load et de trouver un moyen de faire passer les noms de fichiers de ressources à utiliser.

    Son

    On a également à notre disposition les fonctions Mix_LoadWAV_RW et Mix_LoadMus_RW pour appliquer le même traitement aux sons. On écrira des wrappers identiques à celui vu ci-dessus pour ces deux fonctions, utilisant également SAIO_GetResource et permettant la même possibilité de repli vers les variantes non «_RW» des fonctions.

    Exemple : Aliens

    J’ai placé en ligne (voir liens et références) une version d’Aliens, un programme démo trouvé sur le site de la libsdl. Je ne le répète pas ici pour des raisons de concision. Si vous voulez faire le test, téléchargez la version originale d’Aliens et décompressez-la. Pour la compiler et l’essayer, vous aurez besoin des bibliothèques et en-têtes de la SDL et des bibliothèques SDL_Mixer et SDL_Image en plus de l’habituelle chaîne de compilation (make/gcc/ld/libc-devel). Après avoir exécuté ./configure puis make, exécutez ./aliens pour savourer ce chef-d’œuvre du jeu en 2D.Le problème de ce jeu, c’est que si on le déplace, il n’est plus capable de s’exécuter. Difficile alors de l’envoyer à ses amis ou de se le coller à la va-vite sur sa clé USB. Procurez-vous donc le code source d’Aliens et de dopack mis à disposition sur mon site utilisant les techniques vues ci-dessus et recompilez Aliens et dopack.

    Packez les ressources dans dopack comme ci-dessous :

    $ ./dopack aliens data
    Packaging de aliens
    Ajout de data/README
    Ajout de data/alien.gif
    Ajout de data/background.gif
    Ajout de data/explode.wav
    Ajout de data/explosion.gif
    Ajout de data/music.it
    Ajout de data/music.wav
    Ajout de data/player.gif
    Ajout de data/shot.gif
    Ajout de data/shot.wav
    10 resources

    Vous pouvez à présent déplacer l’exécutable seul, l’envoyer à vos amis, etc. Attention, le binaire reste dépendant des bibliothèques, mais on gagne toutefois en facilité de distribution, quand il s’agit d’envoyer des démos ou des versions intermédiaires, ça facilite la vie.

    $ ./aliens

     

    /img-articles/lm/84/art-8/fig-1.jpg

    Conclusion

    Tout au long de l’article, on a travaillé directement sur l’exécutable.
    On pourrait toutefois faire des fichiers de ressources suivant ce principe, sans qu’ils commencent par un exécutable, pour différencier les données de chaque niveau d’un jeu par exemple.
    La première version de ce code n’était pas appliquée à la SDL, mais permettait de packer des ressources à la suite d’un dépackeur, ce qui permet d’obtenir des archives
    auto-extractibles.
    Les applications sont nombreuses et favorisent une distribution aisée, mais ce concept était plus parlant pour des utilisateurs Windows ou OS X.
    Cette version permet, je l’espère, de rassembler un plus large public, bien que le principe soit presque identique.
    En effet, dans l’exemple que nous avons vu, on n’extrait pas les fichiers, on travaille directement sur le pack.

    Références

    • Bibliothèque SDL :
      http://www.libsdl.org/index.php
    • SDL_mixer :
      http://www.libsdl.org/projects/SDL_mixer/
    • SDL_image :
      http://www.libsdl.org/projects/SDL_image/
    • Tutoriel SDL_RWOps/ZLib :
      http://www.kekkai.org/roger/sdl/rwops/rwops.html
    • Game Programming Wiki : http://gpwiki.org/
    • Aliens original : http://www.libsdl.org/projects/aliens/
    • Sources : http://www.xgarreau.org/aide/devel/saio/
    Posté par Xavier Garreau (xgarreau) | Signature : Xavier Garreau | Article paru dans

    Laissez une réponse

    Vous devez avoir ouvert une session pour écrire un commentaire.

    • Il y a actuellement

    • 861 articles/billets en ligne.