Fichiers de ressources & SDL_RWOps

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 un 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 entê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'une entête, on va utiliser une "queue de fichier" qui aura la forme suivante.

Exécutable
Nom de la donnée 1
Taille du nom de la donnée 1
Donnée 1
Taille de la donnée 1
Nom de la donnée 2
Taille du nom de la donnée 2
Donnée 2
Taille de la donnée 2
etc ...
Nombre N de données
Signature SDLALLINONE\0

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 occuppé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 réalisation 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'entê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 resources 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ées, on fixe les noms des pack et répertoire de données.

	printf ("Packaging de %s\n", pack);
	packfd = open (pack, O_APPEND | O_WRONLY);
	if (-1 == packfd) {
		fprintf (stderr, "Impossible d'ouvrir %s pour y ajouter les ressources.\n", pack);
		return 1;
	}

	append_res (packfd, topdir, &nb_resources);

	nb_couples = 2 * nb_resources;

	printf ("%lu resources\n", nb_resources);
	write (packfd, ↦nb_couples, l_size);

	// on ajoute le suffixe
	write (packfd, CHECK_SUFFIX, suffix_len);

	close (packfd);
	return 0;
}

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-&gt;d_name)+2);
			strcpy (tmp_path, path);
			strcat (tmp_path, "/");
			strcat (tmp_path, entry-&gt;d_name);
			if (!strcmp (entry-&gt;d_name, ".")) {
				continue;
			}
			if (!strcmp (entry-&gt;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êment simple. Il s'agit d'un bête parcours de répertoires basé sur les fonctions opendir et readir faisant appel à la récursivité.

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 un simple boucle read/write.

On termine l'ajout en écrivant à la suite des données, la taille qu'elles occuppent, 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 librairie 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 celà 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 un 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. Elle 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 pointeur sur une structure SDL_RWOps ainsi qu'une zone de mémoire, qu'elle allouera contenant les données de la ressource.

Le fait de spécifier le fichier permet de faciliter le passage 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 u nombre d'octets correpondant à 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 est 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'il 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 contruire et retourner une structure SDL_RWOps, grâce à la fonction SDL_RWFromMem qui prends 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.

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'entê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ée 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 rempli 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 voulaez faire le test. Téléchargez la version originale d'aliens et décompressez là. Pour la compiler et l'essayer vous aurez besoin des librairies et entêtes de la SDL et des librairies SDL_Mixer et SDL_Image en plus de l'habiutelle chaîne de compilation (make/gcc/ld/libc-devel).

Après avoir exécuté ./configure puis make, exécutez ./aliens pour savourer ce chez d'oeuvre 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 librairies 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
saioaliens

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é à 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 osx. 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.

Liens & Références :

@+

a+

Auteur : Xavier GARREAU
Modifié le 03.08.2006