Aller au contenu

SCP: Containment Breach en Rust : rétro-ingénierie des formats d'assets

SUKIYAKI
Date de publication:

Depuis la mi-août je passe le plus clair de mon temps libre sur une réimplémentation de SCP: Containment Breach en Rust avec Bevy. Le jeu d’origine date de 2012, il est écrit en Blitz3D, et son code source est public. 816 commits à l’heure où j’écris, du 17 août au 2 septembre 2026.

Autant le dire tout de suite. C’est un projet de fan, dérivé du travail de quelqu’un d’autre. Le jeu de Joonas « Regalis » Rikkonen est sous CC BY-SA 3.0, la Fondation SCP aussi, donc mon dépôt entier l’est également, code et assets compris. Je ne redessine rien. Les modèles, textures et sons viennent du jeu original, je me contente de les convertir d’un conteneur à un autre.

Pas un portage ligne à ligne

Je me suis fixé une règle dès le premier jour. Le source Blitz3D est une référence comportementale, pas un modèle à recopier. Je lis le .bb pour savoir ce que le jeu fait, puis j’écris ma propre version avec ma propre architecture.

Ce qui tourne aujourd’hui, c’est un contrôleur first-person, la génération procédurale seedée de la facility, les portes avec leurs boutons, badges et ascenseurs, le streaming des salles, et SCP-173. Le workspace tient en cinq crates : scpcb (le binaire), scpcb_assets, scpcb_gltf, scpcb_xfile, plus un helper. À côté, une dizaine d’outils CLI hors-ligne. Bevy 0.19, avian3d 0.7, édition Rust 2024.

Mais le gros du temps est parti ailleurs, à faire parler les formats d’assets de 2012.

Le format RMesh

Les salles sont stockées dans un format maison, .rmesh, dont il n’existe aucune spécification. La seule autorité, c’est la fonction de lecture du jeu. J’ai extrait le format à la main depuis le source, puis je l’ai vérifié octet par octet contre les 113 fichiers livrés.

C’est un stream forward-only, sans version, sans table de longueurs, sans chunk framing :

┌─────────────────────────────────────────────┐
│ Magic d'en-tête                     String  │
├─────────────────────────────────────────────┤
│ Surfaces visibles                   i32 + N │
├─────────────────────────────────────────────┤
│ Surfaces de collision invisibles    i32 + N │
├─────────────────────────────────────────────┤
│ Trigger boxes  (uniquement si le magic est  │
│                la variante HasTriggerBox)   │
├─────────────────────────────────────────────┤
│ Entités ponctuelles                 i32 + N │
├─────────────────────────────────────────────┤
│ Trailer : String "EOF"  (jamais lu)         │
└─────────────────────────────────────────────┘

Ce genre de format ne se devine pas, il se mesure. Quelques trouvailles qui auraient silencieusement cassé un parser naïf :

Reste le gros morceau. Le fichier est en espace main gauche (celui de Blitz3D) quand Bevy est en main droite. La conversion (négation de Z, inversion du winding, négation du pitch) est la convention de coordonnées unique du projet, et tout ce qui lit du Blitz3D en dépend.

Les fichiers .x : trois formats, un seul lecteur

Les props et les objets ramassables sont en DirectX .x. J’ai écrit un lecteur dédié, scpcb_xfile. En entrée &[u8], en sortie un modèle ou une erreur. Pas de moteur, pas de filesystem, pas de glTF.

Il y a 56 fichiers dans le corpus, et ils ne sont pas dans un format mais dans trois :

xof 0302txt 0064   17 fichiers   3D World Studio, flottants texte 64 bits
xof 0303txt 0032   38 fichiers   style Blender/3ds, blocs template
xof 0303bzip0032    1 fichier    Battery.x, flux binaire compressé en MSZIP

D’où trois front-ends, un seul flux de tokens, une seule passe sémantique. Le parser ne sait jamais quel lexer l’a alimenté, et c’est ce qui fait trois formats pour un lecteur au lieu de trois parsers.

Les pièges mesurés, là encore :

B3D vers glTF : le convertisseur que je n’ai pas écrit

Les NPC animés sont en .b3d. J’ai évalué trois routes avant d’en choisir une, et j’ai fini par ne rien écrire moi-même. Un convertisseur Rust existant lit le skinning, les animations et surtout le chunk non-standard qui porte les noms de clips. J’en ai forké une copie, appliqué cinq patchs, documenté le pin et chaque patch. Il est sous GPL, donc un test vérifie contre le lockfile qu’aucun crate du jeu n’en dépend. La conversion est hors-ligne, le jeu ne charge que du glTF.

La règle la moins évidente du pipeline concerne les textures. Les entrées .b3d sont souvent des chemins Windows absolus du genre D:\SCP - CB - Vanilla\GFX\npcs\173texture.jpg. Le résolveur essaie le chemin complet, puis le basename seul, et seule cette deuxième stratégie peut rattraper un chemin absolu. Il faut donc pointer le contexte sur le dossier du modèle lui-même. Sur la racine du jeu, il ne trouve rien et sort des modèles sans texture, sans une seule erreur.

Le bake de lightmaps

Le chantier en cours, c’est l’éclairage. Le jeu de 2012 livre ses atlas de lightmaps précalculés, et ils font office d’ancre de fidélité. Je bake les miens à côté, en HDR, dans un DDS Rgba16Float, écrit avec exactement la même version du même crate que celle avec laquelle Bevy le relit, pour que l’écrivain et le lecteur ne puissent pas diverger.

Une seule constante pilote la résolution, la densité en texels par unité monde. Elle a été calibrée sur une petite salle. Mauvaise idée : sur une salle de stockage, mon atlas fait 3,7 fois plus de texels que celui de 2012.

Le plus instructif est venu du playtest. J’avais une fuite de lumière autour d’une porte et j’ai accusé le bake deux fois de suite, à tort. Le vrai coupable était ailleurs. Les lampes temps réel n’avaient pas de shadow maps, donc chacune éclairait à travers le mur derrière elle, et le débordement atterrissait sur les portes et les props. Aucun changement de lightmap ne pouvait corriger ça, ce qui explique pourquoi comparer mon atlas et celui de 2012 donnait deux images identiques au pixel près.

Deux outils en sont sortis, et ce sont les plus utiles du projet. Une touche re-bake l’atlas en mémoire depuis les fichiers de config, sans recompilation. Le second est un panneau de debug egui avec des sliders sur les paramètres d’éclairage.

Où ça en est

C’est jeune, et il reste beaucoup. La facility se parcourt, les portes s’ouvrent, 173 fait ce qu’il doit faire quand on cesse de le regarder. Ce n’est pas le jeu pour autant. Il manque des NPC, des items, la sauvegarde, l’audio complet, et l’éclairage n’est pas fini. Le multijoueur est prévu dans l’architecture mais n’existe pas.

Le plus transférable là-dedans, ce n’est ni le Rust ni Bevy, c’est la méthode. Pour un format non documenté, mesurer tout le corpus avant d’écrire une ligne de parser coûte une journée et évite des semaines de bugs silencieux.

Précédent
Peti Virage : la physique des pneus, de Bevy à Godot