- pslang est né d’un intérêt pour les possibilités de modding des grands jeux et pour l’assembleur généré par les compilateurs C++, et fonctionne aujourd’hui suffisamment pour écrire un path tracer Monte-Carlo d’environ 1 000 LOC
- Un langage de modding doit offrir l’interopérabilité C, la gestion bas niveau des tableaux et pointeurs, un sandboxing facile, un compilateur de petite taille et une compilation rapide ; Lua et les mods natifs en C++ montrent chacun des limites en matière de performances, de liaison, de sandboxing et de déploiement
- pslang est un langage bas niveau impératif, à évaluation immédiate et appel par valeur, avec un système de types statique, strict et nominal, des portées fondées sur l’indentation, des tableaux intégrés, des types de fonctions, des pointeurs et une disposition mémoire garantie
- Le compilateur se divise en un parseur basé sur Bison, une vérification de types sur AST, un IR, un interpréteur et un JIT ; pour l’instant, seule la cible Aarch64 Mac est prise en charge et, depuis l’introduction de l’IR, la qualité du code généré reste faible en raison de l’absence d’allocateur de registres
- L’implémentation actuelle représente environ 10 000 lignes de code C++, et des fonctionnalités comme un allocateur de registres, des optimisations IR, un interpréteur IR, la génération d’exécutables, les informations de débogage, le polymorphisme, les modules et une bibliothèque standard sont à l’étude
Pourquoi créer pslang
- Après environ 17 ans de programmation, l’envie de créer moi-même un langage qui ne soit pas un simple jouet, mais pensé dans une certaine mesure pour un usage réel, est devenue plus forte
- Par le passé, j’ai créé des interprètes de langages ésotériques comme FALSE ainsi que plusieurs interprètes de calcul lambda, mais cela ne satisfaisait pas le désir de construire un « vrai » langage
- Le grand jeu en cours de développement ayant une structure bien adaptée au modding, la réflexion sur la façon de l’ouvrir aux mods a fait émerger l’idée d’un langage de programmation personnalisé comme solution simple
- En regardant Advent of Compiler Optimisations de Matt Godbolt en décembre 2025, je me suis mis à suivre l’assembleur généré par les compilateurs C++, ce qui m’a redonné envie de manipuler l’assembleur
- Le langage est encore loin d’une qualité production, mais il est déjà suffisamment abouti pour écrire un path tracer Monte-Carlo fonctionnel d’environ 1 000 LOC
Exigences du modding et limites des options existantes
- Le jeu simule des centaines de milliers d’entités avec un moteur ECS personnalisé, donc l’objectif est que le langage de modding puisse recevoir des groupes de pointeurs de composants et les parcourir comme une boucle
for en C
- Les mods étant difficiles à contrôler, le sandboxing doit être simple pour protéger les joueurs et, idéalement, il devrait être possible de désactiver tout l’IO et les fonctions similaires avec un simple interrupteur
- Le modding doit être assez simple pour qu’il suffise de déposer des scripts dans un dossier donné pour qu’ils soient immédiatement utilisables comme mods
-
Lua et les langages de script avec JIT
- Lua est le choix classique, mais son sandboxing semble impliquer d’ajouter en prétraitement du code supprimant les fonctions d’IO de la bibliothèque standard avant du code non fiable, ce qui ne paraît pas être une solution robuste
- Lua est un langage dynamique de haut niveau qui ne comprend pas directement les pointeurs C ; pour relier l’itération sur des entités ECS, il faut soit basculer native ↔ Lua ↔ native pour chaque entité, soit transformer les entités natives en tableau Lua avant de les redécomposer
- Lua standard et LuaJIT ont divergé depuis plusieurs versions, ce qui peut créer de la confusion à la fois pour les moddeurs et pour les implémenteurs
-
C++ et les mods natifs
- Avec des mods en C++, le problème de l’itération sur les entités disparaît, mais la distribution de binaires exige un environnement de développement et un dépôt d’artefacts binaires pour toutes les plateformes
- Pour distribuer le code source, il faut intégrer un compilateur C++ au jeu, et une installation LLVM de base occupe déjà 10 à 20 fois plus d’espace disque que le jeu lui-même
- Si une DLL native déclare et utilise
int open();, il devient pratiquement impossible d’empêcher l’accès au système de fichiers ou au réseau, ce qui rend le sandboxing impossible
- Le même problème s’applique à d’autres langages natifs comme Rust
- Le modding est l’un des objectifs, mais il reste incertain que ce langage soit réellement utilisé pour le modding du jeu, et l’idée n’est pas de le surspécialiser pour un usage unique
Objectifs de conception du langage
- L’objectif est de fournir une interopérabilité C fluide afin que la liaison entre le code natif du jeu et le code de modding soit aussi simple qu’un appel de fonction
- Comme il faut manipuler des tableaux bruts d’entités, des fonctionnalités bas niveau sont nécessaires
- Le langage doit rester pratique et agréable à utiliser, afin que les moddeurs puissent écrire du code avec un niveau de confort raisonnable
- Le sandboxing doit être simple, et le compilateur doit aussi rester de petite taille
- Il n’est pas souhaitable d’intégrer un compilateur de 1 Go dans un jeu de 50 Mo, d’où la volonté de réduire l’empreinte du compilateur
- La compilation doit être rapide pour éviter de faire trop attendre les joueurs lors de la compilation des mods, même si un cache étendu peut en atténuer une partie
- L’objectif est un vrai cross-platform, tout en acceptant certaines hypothèses comme quelques plateformes desktop largement utilisées, le 64 bits et la prise en charge d’IEEE754
- Il suffit d’atteindre un niveau de performance raisonnablement rapide par rapport à la plupart des langages dynamiques
- C++ étant resté le langage principal pendant longtemps, il a fortement influencé la vision du langage, mais l’idée est d’éviter autant que possible de simplement recréer C++
Le modèle actuel du langage pslang
- Son nom de travail est pslang, dérivé du moteur de jeu psemek ; c’est un langage impératif, à évaluation immédiate, appel par valeur et bas niveau
- Son système de types est statique, strict et nominal
- L’exemple de base ci-dessous combine fonctions, structures, types de fonctions et retour de tableaux
func min(x: i32, y: i32) -> i32:
return if x < y then x else y
struct vec3i:
x: i32
y: i32
z: i32
func apply(f: i32 -> i32, v: vec3i) -> vec3i:
return vec3i(f(v.x), f(v.y), f(v.z))
func as_array(v: vec3i) -> i32[3]:
return [v.x, v.y, v.z]
Portées et types de base
- Le langage utilise des portées fondées sur l’indentation pour ressembler à un langage de script et paraître plus accessible aux débutants
- L’indentation utilise actuellement des tabulations, mais cela pourrait évoluer vers des espaces plus tard
- Le corps des fonctions, des boucles, des
if, etc. crée une nouvelle portée ; les fonctions et les structures peuvent être définies dans n’importe quelle portée et ne sont visibles qu’à l’intérieur de celle-ci
- Les fonctions locales n’ont pas accès aux variables de la portée où elles sont définies, donc ce ne sont pas des closures ; la portée n’influe que sur la résolution des noms
- La portée de niveau supérieur est traitée comme les autres et contient un point d’entrée exécuté lors du chargement ou de l’initialisation du fichier
- Il existe au total 13 types primitifs :
bool, 4 entiers signés, 4 entiers non signés, 3 flottants et unit
i8 i16 i32 i64
u8 u16 u32 u64
f16 f32 f64
f8 n’est pas inclus, car la plupart des CPU desktop ne le prennent pas en charge et il n’existe pas de consensus sur la signification d’un flottant 8 bits
f16 est moins utile pour le grand public, mais courant en graphisme pour les couleurs HDR, les attributs de sommets, etc., et la plupart des CPU desktop récents implémentent IEEE754 f16, donc il est pris en charge nativement
- Toute l’arithmétique entière suit un modèle en complément à deux avec overflow, sans comportement indéfini
unit n’a qu’une seule valeur, unit(), et constitue le type de retour formel des fonctions sans valeur de retour
- Les fonctions dont le type de retour est omis renvoient automatiquement
unit, et si le return final manque dans ce type de fonction, il est inséré automatiquement
- Ne pas renvoyer de valeur dans une fonction autre qu’une fonction
unit est une erreur
Littéraux, tableaux, types de fonction, pointeurs
- Le nombre
10 est de type i32 par défaut, et sa taille se précise avec des suffixes comme 10b, 10s, 10l
- Les littéraux non signés prennent le suffixe
u, par exemple 10ub, 10us, 10u, 10ul
- Les littéraux à virgule flottante avec un point décimal sont de type
f32 par défaut ; 10.0h correspond à 16 bits et 10.0d à 64 bits
- Il n’est pas possible d’omettre la partie entière ou fractionnaire comme dans
10. ou .5 ; il faut écrire la forme complète, comme 10.0 ou 0.5
- Tous les littéraux numériques ont un type non ambigu
- Les tableaux sont des types intégrés de premier ordre et, contrairement au C/C++, on peut passer, retourner ou affecter un tableau entier entre fonctions
- La taille d’un tableau est toujours connue à la compilation et il se comporte comme une structure ayant plusieurs champs du même type
- Le type d’un tableau s’écrit
i32[5], et un littéral de tableau s’écrit par exemple [1, 2, 3, 4, 5]
- Les types de fonction sont proches des pointeurs de fonction du C, avec la forme
(a, b, c) -> d, et si la fonction n’a qu’un argument, on peut omettre les parenthèses, comme dans a -> b
- En interne, un type de fonction est un simple pointeur de fonction transmis sans données associées ; ce n’est pas une closure
- Les types pointeur s’écrivent comme
i32* ; par défaut, ce sont des pointeurs immuables, et un pointeur mutable se déclare avec i32 mut*
- L’adresse d’une variable s’obtient avec
&x, un pointeur mutable avec &mut x, le déréférencement avec *p, et l’arithmétique des pointeurs s’utilise comme dans *(p + 10)
Structures, disposition mémoire, types vides
- Une structure se déclare avec le mot-clé
struct et une liste de champs
struct string_view:
size: u64
data: u8*
- Une structure se crée via un constructeur fonctionnel intégré, comme
string_view(10, data), et on accède aux champs avec la notation pointée, comme v.x
- On peut aussi accéder aux champs d’un pointeur sur structure avec la même syntaxe par point
- Les champs d’une structure n’ont pas de spécificateur de mutabilité distinct ; les champs d’un objet mutable sont mutables, et ceux d’un objet immuable sont immuables
- Il n’y a pas de modificateurs d’accès, et les champs sont toujours publics
- Tous les objets ont une disposition mémoire garantie ; les types de base ont un alignement égal à leur taille, et
bool occupe 1 octet
- Les types pointeur et fonction font toujours 64 bits et ont le même alignement
- Les tableaux ont le même alignement que leurs éléments, et les structures contiennent du padding pour satisfaire les contraintes d’alignement
- Cette garantie vise surtout à simplifier l’interopérabilité avec le C et les usages en programmation GPU
unit et les structures sans champ sont traités comme des types vides n’ayant qu’une seule valeur valide, et leur taille réelle est de 0 octet
- Passer un type vide à une fonction, le déclarer en variable ou l’utiliser comme champ n’affecte ni l’usage mémoire ni la taille d’une structure
- Les types vides peuvent servir de balises de compilation au niveau des types
- La lecture/écriture via des pointeurs vers des types vides n’est pas encore tranchée, et pour l’instant l’arithmétique de pointeurs sur ces types est interdite
- Le langage ne suit pas la règle du C++ selon laquelle chaque objet possède une adresse mémoire unique
Variables, fonctions, contrôle de flux, fonctions externes
- Une variable immuable se déclare avec
let x = 10, et une variable mutable avec mut x = 20
- On ne peut pas créer de pointeur mutable vers une variable immuable
- Il est possible d’indiquer explicitement le type, comme dans
let x: i32 = 10, mais ce n’est pas obligatoire, car le langage est conçu pour inférer sans ambiguïté le type de toutes les expressions
- Toutes les variables doivent impérativement être initialisées
- Une fonction s’écrit sous la forme
func foo(x: A, y: B) -> C: suivie de son corps ; si le type de retour est omis, il vaut unit
- Toutes les fonctions suivent l’ABI C native de la plateforme d’exécution, afin de faciliter l’interopérabilité C, les callbacks, les systèmes ECS, etc., lorsqu’on les passe comme pointeurs de fonction
- Dans un même scope, l’ordre des déclarations de fonctions et de structures est libre ; on peut utiliser avant leur définition des fonctions ou structures déclarées plus loin
- Comme tous les paramètres et types de retour doivent être entièrement explicités, cette liberté d’ordre de déclaration ne complique pas l’inférence de types
- Il existe des instructions
if/else if/else et des boucles while, mais pas encore de boucle for
- La forme expressionnelle de
if s’utilise comme if A then B else C
- Une fonction externe se déclare comme
foreign func sin(x: f64) -> f64, et son implémentation doit être liée ailleurs
- L’interpréteur actuel recherche ces fonctions dans l’exécutable de l’interpréteur lui-même via
dlsym
- Les fonctions externes sont le principal mécanisme d’interopérabilité avec les bibliothèques C et tierces ; l’exemple de raytracer utilise cette fonctionnalité pour calculer des racines carrées, écrire des fichiers, mesurer le temps et créer des threads
Conversions de type et opérateurs
- Il n’existe aucun cast implicite ; pour convertir manuellement un type, on utilise l’opérateur
as, comme dans (x as f32)
- Tous les types numériques peuvent être convertis entre eux, et tous les types pointeur aussi, sauf qu’on ne peut pas convertir un pointeur immuable en pointeur mutable
- Un type pointeur peut être converti en
u64, et u64 peut être converti en type pointeur
bool ne peut être converti vers aucun autre type, ni depuis aucun autre type
- L’ajout d’un unique cast implicite de
T mut* vers T* est actuellement à l’étude
- Les opérateurs standards, arithmétiques, logiques, de comparaison, etc., sont globalement fournis
&, |, &&, || fonctionnent à la fois sur les booléens et les entiers ; & et | évaluent toujours leurs deux opérandes, tandis que && et || utilisent l’évaluation paresseuse
- Les opérations arithmétiques et les comparaisons ne fonctionnent qu’entre paires de types numériques identiques ; il n’y a pas de promotion des types numériques
- Même si le langage ne semble pas offrir énormément de fonctionnalités pour l’instant, il permet déjà d’écrire des programmes réels de manière relativement confortable
Architecture du compilateur
- Le projet est divisé en plusieurs bibliothèques
types : définition du système de types
ast : définition et utilitaires de l’arbre syntaxique abstrait
parser : parseur
ir : représentation intermédiaire
interpreter : interpréteur
jit : compilateur JIT
- L’idée est de faire de l’interpréteur et du compilateur de simples applications CLI utilisant ces bibliothèques ; pour l’instant, seul l’interpréteur en mode JIT existe
- Pour embarquer le langage, il suffit d’utiliser les bibliothèques
parser et jit
Parseur et gestion de l’indentation
- Le générateur de parseur utilisé est Bison
- Les tokens sont définis dans la lexer grammar, et la grammaire du langage dans la parser grammar
- Un fichier est une liste d’instructions, et une instruction peut être une déclaration de fonction, un opérateur de contrôle de flux, une déclaration de variable, une expression, etc. ; une expression peut être un littéral, une variable, un opérateur, un appel de fonction, etc.
- Il a fallu corriger plusieurs conflits shift/reduce dans la grammaire, et le flag
-Wcounterexamples de Bison a permis d’identifier les situations exactes qui les provoquaient
- Le squelette Bison
lalr1.cc est utilisé pour générer une classe de parseur C++
- Par défaut, Bison génère un parseur C dont l’état est stocké dans des variables globales, ce qui ne convient pas aux cas où plusieurs fichiers doivent pouvoir être analysés en parallèle, comme dans un interpréteur ou un mode jeu
- L’exécution de Bison est intégrée à l’étape de build dans les scripts CMake
- La sortie du parseur est un objet C++ représentant l’AST du fichier analysé
- À cause de l’indentation, la grammaire n’est pas réellement hors contexte : savoir si une instruction appartient au corps d’un
while dépend du nombre de tokens d’indentation qui la précèdent
- La solution consiste à parser chaque ligne comme une instruction indépendante avec son niveau d’indentation, puis à déterminer les scopes dans un simple passage linéaire en se basant sur ces niveaux d’indentation
- Cette approche est un peu bricolée, mais elle fonctionne et elle est très rapide, donc elle est jugée acceptable
- Dans ce même passage, on vérifie aussi que
break et continue n’apparaissent qu’à l’intérieur d’une boucle, que return n’apparaît qu’à l’intérieur d’une fonction, et que les définitions de champs n’apparaissent qu’à l’intérieur d’une structure
Vérification des types et interpréteur
- Après le parsing, la première passe résout tous les identifiants et relie directement les nœuds d’identifiant aux nœuds de définition correspondants de variables, fonctions et structures
- La passe essentielle suivante vérifie et infère tous les types
- L’inférence de types est globalement simple et consiste en des vérifications conditionnelles selon le type de nœud AST concerné
- Par exemple, le type de l’expression dans un
if ou un while doit être bool, et les deux opérandes d’une addition doivent avoir le même type numérique, ou bien l’un doit être un entier et l’autre un pointeur
- L’interpréteur initial est un interpréteur par parcours d’arbre qui visite directement les nœuds AST pour exécuter la sémantique C++
- Les fonctions principales sont
exec() et eval() : exec() exécute une instruction unique et eval() calcule puis renvoie la valeur d’une expression unique
- Comme C++ est à typage statique,
eval() renvoie un variant couvrant tous les types de valeur possibles du langage
- Les structures sont représentées comme des tableaux de paires nom-valeur, une par champ, et le même
variant est aussi utilisé pour stocker les valeurs des variables
- Le but de l’interpréteur est d’exécuter le code du langage de manière cross-platform, et d’aider au débogage de l’implémentation comme des programmes ; il n’a pas vocation à être rapide
- L’interpréteur actuel est dans un état très cassé, et il est prévu de le réécrire entièrement sur une base IR
- L’interpréteur existant ne peut pas exécuter les fonctions
foreign
- Les fonctions
foreign doivent être appelées selon la convention d’appel C, et comme le nombre et le type des arguments ne peuvent pas être connus à l’avance, il faudra probablement utiliser une technique vararg ou libffi
- L’interpréteur peut dumper son état interne — c’est-à-dire les noms, types et valeurs des variables — sur stdout, et c’était le principal moyen de déboguer le parseur et l’interpréteur avant la création d’un véritable compilateur
Premier compilateur JIT Aarch64
- Début janvier 2026, pendant des vacances où il n’y avait qu’un Mac M1 sous la main, la première architecture cible du compilateur est donc devenue l’Aarch64 sur Mac
- C’est aussi la seule architecture prise en charge actuellement
- Le compilateur fonctionne en JIT, et le résultat est un blob mémoire mappé avec le bit exécutable, accompagné de pointeurs vers les points d’entrée de chaque fonction
- La structure de haut niveau se rapproche beaucoup d’un compilateur classique basé sur une pile, mais les résultats des expressions sont placés comme le ferait une fonction du même type de retour dans l’AAPCS64, la convention d’appel C standard des Mac Aarch64
- Les entiers et pointeurs sont renvoyés dans le registre général
x0, les flottants dans le registre flottant v0, et les structures sont renvoyées via des registres ou la pile selon leur taille
- Cette approche réduit le nombre d’accès mémoire, accélère le code généré et simplifie aussi les appels de fonctions
- La pile sert surtout aux résultats intermédiaires, par exemple dans les opérations binaires
(eval A) # the value of A is in x0
push x0 # the value of A is on stack top
(eval B) # the value of B is in x0
pop x1 # the value of A is in x1
add x0, x0, x1 # the value of A+B is in x0
- Les structures de contrôle sont transformées en sauts conditionnels, mais dans une compilation en une seule passe, la cible du saut est inconnue tant que le corps du
if ou du while n’a pas encore été compilé
- Pour résoudre cela, une instruction de saut avec un offset nul est d’abord émise, puis l’offset réel est injecté une fois l’offset cible connu
- La même méthode s’applique aussi aux appels de fonctions
- Aucune bibliothèque tierce n’est utilisée pour générer les instructions CPU ; afin de garder le compilateur petit, cela a été implémenté à la main
- L’implémentation a consisté à fouiller le manuel d’instructions pour y repérer puis renseigner les bits nécessaires
Ce qui était délicat sur Aarch64
- Toutes les instructions Aarch64 font 32 bits, ce qui semble pratique, mais pour placer une constante de 32 bits dans un registre il faut à la fois des bits de sélection de registre, des bits d’instruction et des bits de constante, ce qui ne tient pas dans une seule instruction de 32 bits
- Les constantes 64 bits posent un problème encore plus grand
- Les constantes doivent être assemblées à partir d’instructions chargeant des morceaux de 16 bits aux offsets 0, 16, 32 et 48 bits, ou bien placées en mémoire constante puis chargées depuis celle-ci
- Pour les constantes flottantes, l’approche utilisée consiste à les charger depuis la mémoire constante
- Contrairement au x86, il n’y a pas d’instructions push/pop ; il faut combiner des instructions qui lisent/écrivent entre registres et adresses mémoire tout en ajustant le registre d’adresse
- Comme toutes les instructions font exactement 32 bits, il faut sans cesse faire attention au fait qu’un offset soit signé ou non signé, qu’il soit pré-multiplié par une certaine constante, qu’il modifie ou non le registre d’adresse, etc.
- Lorsqu’on lit ou écrit la pile par rapport au registre SP, le pointeur de pile doit toujours rester aligné sur 16 octets
- Les offsets possibles sont limités à 12 bits ; quand une stack frame dépasse environ 16 KB, il faut du code spécial, qui n’est pas encore implémenté
- La convention d’appel comporte des cas particuliers où les structures sont passées ou renvoyées via jusqu’à 2 registres généraux, des registres flottants ou un pointeur mémoire, et le compilateur doit gérer cela
Introduction de l’IR et deuxième compilateur
- Après avoir créé l’interpréteur de base et le compilateur, une représentation intermédiaire (IR) a été introduite pour réutiliser du code, simplifier l’écriture de compilateurs pour d’autres architectures et permettre des optimisations
- L’IR a commencé comme quelque chose de proche de la SSA, mais comme il est possible de réaffecter des valeurs au même nœud et qu’aucun nœud phi n’est utilisé, ce n’est en réalité pas de la SSA
- L’IR est une séquence de nœuds, chaque nœud représentant un littéral, une opération avec des nœuds d’entrée, un saut conditionnel ou inconditionnel, un appel de fonction, etc.
- Les nœuds représentant des valeurs stockent aussi le type de cette valeur
- Comme les réaffectations sont autorisées, il existe une instruction IR
assign qui réassigne la valeur d’un nœud existant
- Les sauts conditionnels sont séparés entre
jump_if_zero et jump_if_nonzero ; cela correspond généralement à des instructions CPU différentes et c’est plus rapide que de nier la valeur puis d’utiliser l’instruction opposée
- Comme les pointeurs de fonction sont pris en charge, il existe une instruction distincte pour appeler un nœud IR connu et une autre pour appeler une valeur de pointeur inconnue
- Pour faciliter les optimisations qui doivent supprimer ou insérer des nœuds à des positions arbitraires, les nœuds sont stockés dans une
std::list et les références sont des itérateurs de liste
- Il n’est pas possible de créer des littéraux de valeur de structure, donc il existe un nœud
alloc représentant une valeur de structure, généralement compilé sous la forme d’un espace de structure non initialisé alloué sur la pile
- Les structures sont construites en affectant leurs champs individuellement
- Si l’on représente naïvement un champ imbriqué de structure comme
a.x.y, on lit a.x dans un nouveau nœud puis on lit y de ce nœud, ce qui est très gaspilleur
- De même, si
a.x.y = b est représenté comme t = a.x, t.y = b, a.x = t, cela devient inefficace ; l’IR traite donc spécialement les champs imbriqués
- Un nœud
copy peut extraire n’importe quel champ imbriqué d’une structure, et un nœud assign peut affecter n’importe quel champ imbriqué d’une structure
- Les champs imbriqués sont représentés comme des tableaux d’indices du type « prendre le champ 0, puis à l’intérieur le champ 2, puis à l’intérieur le champ 5 »
- Le compilateur Aarch64 a ensuite été réécrit en le séparant entre un compilateur AST → IR et un compilateur IR → Aarch64
- La partie AST → IR est relativement simple, mais le compilateur IR → Aarch64 est actuellement dans un état bien pire que l’ancien compilateur basé sur une pile
- Au début de chaque fonction, il alloue assez d’espace sur la pile pour tous les nœuds IR nécessaires à cette fonction ; en conséquence, même la plupart des valeurs intermédiaires à durée de vie très courte occupent de la place dans la stack frame
- Une fonction du raytracer a dû être scindée en deux pour faire tenir sa stack frame dans la limite de 12 bits évoquée plus haut
- Ce compilateur part du principe qu’un allocateur de registres sera utilisé ensuite, donc le code généré devrait plus tard s’améliorer de plusieurs ordres de grandeur
Plan pour le compilateur et l’interpréteur
- L’implémentation actuelle se compose d’environ 10 000 lignes de code C++, et l’auteur est satisfait du fait que le compilateur soit petit selon les standards actuels tout en fonctionnant réellement
-
Allocateur de registres
- Le compilateur actuel IR → Aarch64 a absolument besoin d’un allocateur de registres
- Le plan est d’utiliser un allocateur standard par linear scan, comme compromis entre vitesse de compilation et qualité du code
-
Optimisation de l’IR
- L’objectif est d’ajouter, à partir de l’IR, la propagation de constantes, la simplification arithmétique, l’élimination du code mort, l’inlining et le déroulage de boucle
- Il ne s’agit pas de battre GCC ou LLVM, mais de faire en sorte que des fonctions simples comme l’addition de vecteurs 3D soient compilées en aussi peu d’instructions CPU que possible
-
Interpréteur IR
- Le plan est de réécrire l’interpréteur pour qu’il évalue directement l’IR, ce qui devrait le rendre nettement plus simple
-
Génération d’exécutables
- Le compilateur actuel ne génère pour l’instant qu’un blob mémoire JIT destiné à être exécuté immédiatement
- L’auteur veut aussi produire des binaires exécutables dans des formats spécifiques à chaque plateforme, ce qui implique de se plonger dans les spécifications de formats binaires comme ELF, Mach-O et PE
- Produire des exécutables aussi petits que possible fait également partie des objectifs
-
Débogage
- L’auteur a beaucoup suivi l’assembleur généré par le JIT dans lldb, et aimerait pouvoir déboguer correctement le langage lui-même
- Pour cela, il faudra très probablement prendre en charge le format d’informations de débogage DWARF, sur lequel il ne connaît presque rien pour l’instant
Fonctionnalités de langage à ajouter
-
Constructeurs de structures
- Actuellement, les structures permettent seulement soit de renseigner tous les champs, comme
vec3i(1, 2, 3), soit de les initialiser à zéro, comme vec3i()
- L’idée envisagée est que déclarer une fonction portant le même nom que la structure la fasse agir comme un constructeur arbitraire
func vec3i(x: i32, y: i32) -> vec3i:
return vec3i(x, y, 0)
- Cela dit, il pourrait être préférable de donner à ce type de fonction un nom distinct, donc rien n’est encore arrêté
-
Variables globales
- Les variables globales ne sont actuellement pas prises en charge
- Le plan est d’ajouter des variables globales avec le mot-clé
global, tout en gardant les restrictions des règles de portée, ce qui permettrait d’avoir des variables globales locales à une fonction, comme les variables static en C
- Les variables de niveau supérieur ne sont pas de vraies globales sauf si
global est utilisé ; sinon, ce sont des variables locales de la fonction point d’entrée du fichier
- Cette structure pourrait être déroutante pour les utilisateurs, donc d’autres options sont aussi à l’étude
- Comme Mac n’autorise pas simultanément des mappings mémoire à la fois inscriptibles et exécutables, les variables globales devront peut-être être allouées séparément du code et mappées avec d’autres flags
- L’accès global devra peut-être passer par des adresses résolues à l’exécution plutôt que par des offsets connus à la compilation
- Il semble toutefois possible de modifier les flags d’une partie du mapping avec
mprotect(), donc l’auteur compte essayer cela d’abord
-
Syntaxe d’appel de méthode
- Pour la lisibilité, l’auteur veut que
x.f(y) puisse, lorsque c’est possible, signifier f(&x, y) ou f(&mut x, y)
-
Polymorphisme
- C’est considéré comme la fonctionnalité potentielle la plus importante
- Les options les plus probables sont soit une surcharge de fonctions à la C++ avec des templates de fonctions et de structures sans restriction, soit des
trait explicites à la Haskell/Rust avec fonctions et structures génériques contraintes par des trait
- Le style C++ est plus puissant, plus lisible dans les cas simples et plus facile à implémenter côté compilateur, mais peut produire des messages d’erreur extrêmement obscurs
- Les
trait explicites peuvent être plus lisibles dans certains cas et résoudre le problème des messages d’erreur, mais exigent un nouveau système de trait et de trait bound, ce qui complique l’implémentation du compilateur
- Rien n’est encore décidé, mais malgré la volonté de ne pas recréer C++, l’auteur penche fortement vers la première option
struct vec2<t: type>:
x: t
y: t
func min<t: type>(x: t, y: t) -> t:
return if x < y then x else y
- L’inférence des arguments de fonction est également souhaitée quand c’est possible
-
Surcharge d’opérateurs
- Cela nécessite du polymorphisme, quelle que soit la forme retenue
a + b pourrait appeler une fonction surchargée comme add(a, b) ou une méthode de trait comme Add::add
-
Boucles for
- Comme on peut déjà les simuler avec
while, les boucles for sont plutôt envisagées comme des boucles basées sur des collections, à la manière des range-based loops de C++ ou des boucles Python
- Cela suppose une interface range/iterator, et donc à nouveau du polymorphisme
-
Gestion automatique des ressources
- L’auteur estime qu’un langage pratique et agréable à utiliser doit proposer un moyen d’aider à libérer des ressources comme la mémoire, les fichiers, les sockets ou les mutex
- Les pistes envisagées sont le RAII et les move à la C++, le
defer à la Zig, et les types linéaires
- Le RAII a l’inconvénient d’être implicite et d’ajouter des commandes cachées ainsi que du flux de contrôle implicite
defer est explicite, mais doit être ajouté manuellement à chaque fois, n’empêche pas les oublis, et devient peu pratique lorsqu’il faut libérer des collections imbriquées, comme un tableau de fichiers
defer free(array)
defer for file in array:
close(file)
- Les types linéaires semblent prometteurs, car ils permettent de conserver l’explicite d’appels manuels à
free ou close tout en imposant que les objets soient consommés par des fonctions de libération de ressources
- Mais comme ils s’articulent mal avec des collections imbriquées comme des tableaux dynamiques de fichiers, rien n’est encore tranché
-
Littéraux polymorphes
- Un tableau vide
[] permet de savoir que sa taille est 0, mais pas d’inférer le type de ses éléments
null peut représenter n’importe quel type de pointeur, et le littéral inf que l’auteur souhaite ajouter pourrait représenter n’importe quel type à virgule flottante
- Trois solutions sont envisagées : des littéraux polymorphes à la Haskell, des types spéciaux intégrés ou de bibliothèque avec conversions implicites comme le
nullptr_t de C++, ou des littéraux spéciaux dans l’AST avec un traitement ad hoc par le compilateur
- Pour l’instant, l’auteur penche vers cette dernière approche, en n’autorisant
null que là où le type de pointeur attendu est connu, comme dans l’initialisation explicite d’une variable typée ou dans le passage d’un argument de fonction
- Cette approche est la plus simple, mais elle n’est pas extensible et ne permet donc pas de construire des types personnalisés à partir de
null
-
Évaluation à la compilation
- L’auteur souhaite introduire le mot-clé
const pour déclarer des variables de compilation, utilisables dans des expressions évaluées à la compilation comme les tailles de tableau
- Une valeur
const ne pourra pas être réaffectée ni voir son adresse prise
- Des fonctions appropriées pourront être appelées dans des expressions de compilation lorsqu’elles n’accèdent pas à des variables globales et n’ont pas d’effets de bord
- Le corps de la fonction se comportera comme une fonction normale, mais sera exécuté pendant la compilation et son résultat deviendra une expression de compilation
- Il faudra un mécanisme pour marquer des fonctions
foreign sûres à appeler à la compilation, comme des fonctions mathématiques ou d’allocation mémoire
-
Calcul sur les types
- L’auteur veut prendre en charge des calculs sur les types à des fins de métaprogrammation
- Il ne souhaite pas créer un encodage de types à l’exécution dans un langage statiquement typé, et l’utilité des types à l’exécution lui paraît limitée, donc cela est prévu uniquement pour la compilation
- Il estime que des fonctionnalités proches des concepts de C++ pourraient aussi être implémentées via des appels évalués à la compilation, sans syntaxe dédiée
func comparable(t: type) -> bool:
// Implemented somehow...
func min<t: comparable type>(x: t, y: t) -> t:
return if x < y then x else y
-
Coroutines
- L’ajout d’
async/await à la manière de Python ou de JS relève davantage du souhait que d’un véritable plan
Plan pour les bibliothèques et les modules
-
Modules
- Écrire tout le code dans un seul fichier n’est pas réaliste, donc des modules sont nécessaires
- L’idée est de prévoir une syntaxe simple comme
import lib.sublib, qui peut être placée n’importe où dans le code et suit aussi les règles de portée
- La portée n’affecte que la visibilité ; le chargement réel a lieu à la compilation, et le point d’entrée du module importé est exécuté avant le module courant
- Le nom de la bibliothèque correspond directement à un chemin du système de fichiers, relatif à un chemin racine indiqué au compilateur ou à l’interpréteur
- S’il s’agit d’un seul fichier source, seul ce fichier est importé ; s’il s’agit d’un répertoire, tous les fichiers de ce répertoire sont importés dans un certain ordre
- Il faut une syntaxe pour désigner des fichiers du même répertoire, et une forme comme
import .another est envisagée
- Les fonctions importées et les variables globales peuvent être utilisées sans préfixe, et en cas d’ambiguïté, on peut ajouter le préfixe du nom de la bibliothèque, comme dans
io.print(x)
- Le point d’entrée des modules devrait être exécuté dans un ordre déterministe, défini par l’ordre des imports et le tri topologique des imports récursifs, ce qui pourrait résoudre les problèmes d’ordre d’initialisation en C ou C++
- L’organisation mémoire d’un programme à plusieurs modules n’est pas encore décidée
- Il serait possible d’attribuer à chaque module son propre segment mémoire et de résoudre à l’exécution les appels de fonctions et les accès aux variables globales, ou bien de construire un seul grand mapping mémoire et d’utiliser des offsets relatifs
- Un grand mapping unique peut être plus rapide à l’exécution, mais il complique la compilation parallèle de plusieurs modules
-
Prelude
- Avec l’arrivée des modules, les utilitaires de base pourront être placés dans un module prelude implicitement inclus dans tous les programmes
- Parmi les candidats figurent une fonction
length() pour les tableaux intégrés, une interface d’iterator, un type string view, ainsi que des plages numériques comparables au range(n) de Python
-
Littéraux de chaîne
- Il n’existe pas encore de littéraux de chaîne, et leur sémantique n’a pas encore été tranchée
- Le plan consiste à définir dans le prelude un type immutable
string_view, à placer le contenu des chaînes quelque part dans la mémoire exécutable, puis à remplacer chaque littéral par un string_view pointant vers cette mémoire
-
Bibliothèque standard
- Une fois les modules en place, une bibliothèque standard sera également nécessaire
- Le périmètre envisagé comprend une bibliothèque mathématique avec vecteurs et matrices, une gestion mémoire de type
alloc/free liée depuis libc, des tableaux dynamiques, des chaînes dynamiques et du formatting, des tables de hachage, les IO console et fichier, des helpers pour le système de fichiers, des helpers pour le temps et l’horloge, ainsi que le networking
Priorités actuelles
- Il n’est pas encore décidé quand ces fonctionnalités seront implémentées, ni si ce langage sera réellement utilisé pour le modding de jeux ou pour d’autres usages
- Mener sérieusement plusieurs projets ambitieux en parallèle n’est pas une bonne idée, et la priorité actuelle reste donc le développement de jeux
- Comme il faut d’abord créer un jeu avant de pouvoir le modder, le travail sur le langage avance seulement quand l’envie s’en fait sentir
1 commentaires
Avis sur Lobste.rs
Les commentaires ici semblent bien plus durs que ce à quoi je m’attendais dans cette communauté
Il est possible qu’une autre langue comme Lua aurait largement suffi. Il est aussi possible que l’auteur se soit lancé dans un énorme yak shaving
Cela dit, il est clairement très compétent et prend énormément de plaisir à faire ça, et l’article contient aussi des éléments techniques intéressants
Si c’est le billet d’un collègue nerd qui conçoit encore un langage de script pour moteur de jeu, je le lirai volontiers avec plaisir. Si ça peut m’éviter un article généré par IA expliquant qu’un déchet SaaS fabriqué en vibecoding va sauver le monde et rendre son auteur riche, je peux lire mille billets comme celui-ci par jour
L’affirmation selon laquelle « Lua ou un autre langage de script compilé en JIT est le choix standard, mais le sandboxing y est vraiment difficile » est franchement difficile à comprendre
Le fait que le sandboxing de Lua soit facile est l’un de ses plus grands atouts, et pas seulement pour les mods ou les plugins. Aucun autre langage que j’ai vu ne s’en approche
Le problème des versions de Lua n’est pas totalement sans fondement, mais en pratique je ne vois pas beaucoup de gens s’en plaindre violemment. À moins d’utiliser un Lua « moderne » pour un usage donné puis de devoir redescendre en 5.1/5.2 pour autre chose, la plupart des gens semblent n’utiliser que l’un ou l’autre
On a vraiment l’impression d’une enquête menée pour justifier l’idée de « créer mon propre langage ». Ce n’est pas un problème en soi, mais autant être honnête plutôt que d’affirmer des choses complètement fausses sur les options existantes
Si l’on s’intéresse à la conception de VM ou à des couches plus basses, alors l’approche décrite dans l’article est bien sûr valable. Mais c’est loin d’être la meilleure manière d’apprendre la conception de langages
L’exemple le plus simple est l’évasion via bytecode. Une fois qu’on sait que cela existe, on peut le désactiver, mais le fait que cela revienne régulièrement révèle un problème plus large. Il faut comprendre comment des parties éloignées de la spécification Lua interagissent pour assembler des règles de sandboxing ; ce n’est pas une structure où l’on peut composer un programme sûr à partir d’éléments de base clairement définis quant aux interactions supplémentaires qu’ils autorisent
Exemple plus tiré par les cheveux : la pollution de prototypes entre environnements différents au sein de la même VM Lua. Dans Redis, il était possible de polluer la metatable de
string, ce qui permettait ensuite d’exécuter du code avec les privilèges d’un autre utilisateur de la base de données utilisant des fonctionnalités Lua. Lua a une surface de pollution de prototypes astronomiquement plus petite que JavaScript, mais c’est assez drôle de voir qu’avec à peu près seulement deux prototypes globaux, on peut quand même faire exactement la même chose avec l’un d’euxCela dit, Luau dispose d’une solution assez solide à ce problème, et je ne vois pas bien pourquoi l’auteur considère implicitement qu’en créant un nouveau sandbox il éviterait automatiquement tous les mêmes problèmes
Le passage disant : « Mon jeu repose énormément sur la simulation. Il simule des centaines de milliers d’entités avec un moteur ECS maison. Idéalement, le langage de modding devrait pouvoir recevoir plusieurs pointeurs de composants et les parcourir comme une boucle
foren C » pourrait viser un idéal meilleurIl vaudrait notamment la peine de comparer la manière dont des moteurs de rendu comme Unity, Unreal, Blender ou Godot traitent ce problème. L’itération externe n’est pas assez rapide pour parler de mégapixels par seconde, et elle pourrait ne pas convenir non plus à des centaines de milliers d’entités par seconde. Ici, il faut penser parallélisme
Les grands moteurs sont tous pensés pour le GPU et utilisent généralement une description en dataflow d’algorithmes sans branchement ridiculement parallélisables. L’auteur peut ne pas aimer les éditeurs visuels, et cette réaction est fréquente, mais cela ne veut pas dire qu’une boucle
forsoit la réponseSi l’auteur avait mentionné que l’ECS est fondamentalement un paradigme relationnel, et que le langage historiquement chargé auquel il faudrait le comparer est SQL, j’aurais peut-être été un peu plus indulgent