- pslang entstand aus dem Interesse an der Modding-Fähigkeit großer Spiele und an dem Assemblercode, den C++-Compiler erzeugen, und funktioniert inzwischen gut genug, um einen Monte-Carlo-Path-Tracer mit rund 1.000 LOC zu schreiben
- Eine Modding-Sprache benötigt C-Interoperabilität, Low-Level-Verarbeitung von Arrays und Zeigern, einfaches Sandboxing, einen kleinen Compiler und schnelle Kompilierung; Lua und native C++-Mods zeigen jeweils Grenzen bei Performance-Anbindung, Sandboxing und Verteilung
- pslang ist eine imperative, strikt ausgewertete Low-Level-Sprache mit Call-by-Value und bietet ein statisches, strenges, nominales Typsystem, einrückungsbasierte Scopes, eingebaute Arrays, Funktionstypen, Zeiger und garantiertes Memory Layout
- Der Compiler ist in einen Bison-basierten Parser, AST-Typprüfung, IR, Interpreter und JIT aufgeteilt; aktuell wird nur Aarch64 Mac unterstützt, und nach der Einführung von IR ist die Qualität des erzeugten Codes wegen des fehlenden Registerallokierers noch niedrig
- Die aktuelle Implementierung umfasst etwa 10.000 Zeilen C++-Code; als Nächstes stehen möglicherweise ein Registerallokierer, IR-Optimierungen, ein IR-Interpreter, die Erzeugung von Executables, Debugging-Informationen, Polymorphie, Module und eine Standardbibliothek an
Warum pslang entstanden ist
- Nach etwa 17 Jahren Programmiererfahrung wuchs der Wunsch, selbst eine Sprache zu entwickeln, die nicht nur ein Spielzeug ist, sondern zumindest teilweise auf praktische Nutzung abzielt
- In der Vergangenheit entstanden zwar Interpreter für obskure Sprachen wie FALSE und mehrere Lambda-Kalkül-Interpreter, doch das stillte nicht den Wunsch, eine „echte“ Sprache zu bauen
- Das in Entwicklung befindliche große Spiel ist strukturell gut für Modding geeignet, und bei der Suche nach einem Modding-Ansatz erschien eine eigene Programmiersprache als eine der einfacheren Lösungen
- Beim Anschauen von Matt Godbolts Advent of Compiler Optimisations im Dezember 2025 begann die Verfolgung des von C++-Compilern erzeugten Assemblercodes, was erneut Lust machte, mit Assembler zu arbeiten
- Die Sprache ist derzeit noch weit von Produktionsqualität entfernt, wurde aber bereits so weit umgesetzt, dass sich ein funktionierender Monte-Carlo-Path Tracer mit etwa 1.000 LOC darin schreiben lässt
Modding-Anforderungen und Grenzen bestehender Optionen
- Das Spiel simuliert mit einer eigenen ECS-Engine Hunderttausende Entitäten, daher soll die Modding-Sprache Bündel von Komponenten-Zeigern entgegennehmen und wie eine C-
for-Schleife darüber iterieren können
- Mods sind schwer zu kontrollieren, daher muss Sandboxing zum Schutz der Spieler einfach sein; idealerweise sollten sich sämtliche I/O- und ähnliche Funktionen mit einem einzigen Schalter deaktivieren lassen
- Modding soll so einfach sein, dass Skripte in einen bestimmten Ordner gelegt werden und sofort als Mod nutzbar sind
-
Lua und JIT-Skriptsprachen
- Lua ist die Standardwahl, doch Sandboxing scheint dort Vorverarbeitungscode zu erfordern, der vor nicht vertrauenswürdigem Code I/O-bezogene Funktionen aus der Standardbibliothek entfernt, was nicht wie eine robuste Lösung wirkt
- Lua ist eine dynamisch typisierte High-Level-Sprache und versteht C-Zeiger nicht direkt; um die ECS-Entitätsiteration anzubinden, müsste daher entweder pro Entität zwischen native ↔ Lua ↔ native gewechselt werden, oder native Entitäten würden erst in Lua-Arrays umgewandelt und später wieder zerlegt
- Standard-Lua und LuaJIT haben sich seit einigen Versionen auseinanderentwickelt, was sowohl Modder als auch Implementierer verwirren kann
-
C++ und native Mods
- Werden Mods in C++ geschrieben, entfällt zwar das Problem der Entitätsiteration, doch die Verteilung von Binärdateien erfordert Entwicklungsumgebungen für alle Plattformen und ein Repository für Binärartefakte
- Für die Verteilung als Quellcode müsste das Spiel einen C++-Compiler mitliefern; schon eine Standard-LLVM-Installation belegt derzeit 10- bis 20-mal so viel Speicherplatz wie das Spiel selbst
- Wenn eine native DLL
int open(); deklariert und verwendet, ist es praktisch unmöglich, Datei- oder Netzwerkzugriffe zu verhindern; Sandboxing ist damit nicht machbar
- Dasselbe Problem gilt auch für andere native Sprachen wie Rust
- Modding ist zwar eines der Ziele, aber noch ist unklar, ob diese Sprache tatsächlich für Spielmodding genutzt wird, und sie soll nicht zu stark auf einen speziellen Anwendungsfall zugeschnitten werden
Ziele beim Sprachdesign
- Eine nahtlose C-Interoperabilität soll die Verbindung zwischen nativem Spielcode und Modding-Code so einfach wie einen Funktionsaufruf machen
- Da mit rohen Entitätsarrays gearbeitet werden muss, sind Low-Level-Funktionen erforderlich
- Die Sprache soll praktisch und angenehm nutzbar sein, damit Modder mit vertretbarem Komfort Code schreiben können
- Sandboxing soll einfach sein, und auch der Compiler selbst soll klein bleiben
- In ein Spiel mit 50 MB soll kein Compiler mit 1 GB gepackt werden, daher soll der Compiler-Footprint klein bleiben
- Die Kompilierung muss schnell sein, damit Spieler nicht lange auf die Übersetzung von Mods warten; ein Teil davon lässt sich durch umfangreiches Caching abfedern
- Echte Cross-Platform-Unterstützung ist erwünscht, auch wenn Annahmen wie einige weit verbreitete Desktop-Plattformen, 64 Bit und IEEE754 akzeptiert werden
- Im Vergleich zu den meisten dynamischen Sprachen reicht eine vernünftig schnelle Ausführung aus
- Da C++ lange die Hauptsprache war, hat sie die Sicht auf Programmiersprachen stark geprägt, dennoch soll nach Möglichkeit nicht einfach C++ neu erschaffen werden
Das aktuelle Sprachmodell von pslang
- Der Arbeitsname lautet pslang, nach der Game Engine psemek; es ist eine imperative, strikt ausgewertete Low-Level-Sprache mit Call-by-Value
- Das Typsystem ist statisch, streng und nominal
- Das Grundbeispiel zeigt zugleich Funktionen, Strukturen, Funktionstypen und die Rückgabe von Arrays
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]
Scopes und Grundtypen
- Es werden einrückungsbasierte Scopes verwendet, damit die Sprache wie eine Skriptsprache wirkt und für Einsteiger zugänglicher erscheint
- Aktuell basiert die Einrückung auf Tabulatoren, könnte später aber auf Leerzeichen umgestellt werden
- Funktionen, Schleifenkörper,
if-Blöcke usw. erzeugen neue Scopes; Funktionen und Strukturen können innerhalb jedes Scopes definiert werden und sind nur dort sichtbar
- Lokale Funktionen können nicht auf Variablen des Scopes zugreifen, in dem sie definiert wurden; sie sind also keine Closures, und Scopes beeinflussen nur die Namensauflösung
- Der Top-Level-Scope wird wie jeder andere Scope behandelt und enthält den Entry Point, der beim Laden oder Initialisieren der Datei ausgeführt wird
- Es gibt insgesamt 13 Grundtypen:
bool, vier vorzeichenbehaftete Ganzzahltypen, vier vorzeichenlose Ganzzahltypen, drei Fließkommatypen und unit
i8 i16 i32 i64
u8 u16 u32 u64
f16 f32 f64
f8 ist nicht enthalten, weil es von den meisten Desktop-CPUs nicht unterstützt wird und es auch keine Einigung über die Bedeutung von 8-Bit-Gleitkommazahlen gibt
f16 ist für allgemeine Nutzer zwar weniger nützlich, wird aber in der Grafik häufig für HDR-Farben, Vertex-Attribute usw. verwendet, und da die meisten modernen Desktop-CPUs IEEE754-f16 implementieren, wird es standardmäßig unterstützt
- Sämtliche Ganzzahlarithmetik verwendet Zweierkomplement mit Overflow, es gibt also kein undefiniertes Verhalten
unit besitzt nur den einen Wert unit() und ist der formale Rückgabetyp für Funktionen ohne Rückgabewert
- Bei Funktionen ohne expliziten Rückgabetyp wird automatisch
unit zurückgegeben; fehlt am Ende einer solchen Funktion ein return, wird es automatisch eingefügt
- Wird in einer Nicht-
unit-Funktion kein Wert zurückgegeben, ist das ein Fehler
Literale, Arrays, Funktionstypen, Pointer
- Die Zahl
10 ist standardmäßig i32, und die Größe wird mit Suffixen wie 10b, 10s, 10l angegeben
- Vorzeichenlose Literale erhalten das Suffix
u und werden etwa als 10ub, 10us, 10u, 10ul geschrieben
- Gleitkomma-Literale mit Dezimalpunkt sind standardmäßig
f32; 10.0h ist 16 Bit, 10.0d ist 64 Bit
- Der Ganzzahl- oder Nachkommateil darf nicht weggelassen werden wie in
10. oder .5; stattdessen muss vollständig geschrieben werden, also 10.0, 0.5
- Alle numerischen Literale haben einen eindeutigen Typ
- Arrays sind eingebaute First-Class-Typen und können im Gegensatz zu C/C++ als Ganzes an Funktionen übergeben, von ihnen zurückgegeben oder einander zugewiesen werden
- Die Array-Größe ist immer zur Compile-Zeit bekannt und das Array verhält sich wie eine Struktur mit mehreren Feldern desselben Typs
- Array-Typen werden als
i32[5], Array-Literale als [1, 2, 3, 4, 5] geschrieben
- Funktionstypen ähneln C-Funktionszeigern und werden im Format
(a, b, c) -> d geschrieben; bei einem Argument können die Klammern weggelassen werden, also a -> b
- Intern sind Funktionstypen gewöhnliche Funktionszeiger, bei denen keine Daten mit übergeben werden; sie sind keine Closures
- Pointer-Typen werden wie
i32* geschrieben, sind standardmäßig immutable Pointer und mutable Pointer werden als i32 mut* deklariert
- Die Adresse einer Variable ist
&x, ein mutabler Pointer &mut x, Dereferenzierung ist *p, und Pointer-Arithmetik wird wie *(p + 10) verwendet
Strukturen, Speicherlayout, leere Typen
- Strukturen werden mit dem Schlüsselwort
struct und einer Feldliste deklariert
struct string_view:
size: u64
data: u8*
- Strukturen werden mit einem eingebauten funktionalen Konstruktor wie
string_view(10, data) erzeugt, auf Felder wird mit einem Punkt wie in v.x zugegriffen
- Auch bei Struktur-Pointern kann mit derselben Punktsyntax auf Felder zugegriffen werden
- Für Strukturfelder gibt es keinen separaten Mutability-Bezeichner; Felder eines mutablen Objekts sind mutable, Felder eines immutable Objekts sind immutable
- Es gibt keine Zugriffsspezifizierer, Felder sind immer public
- Alle Objekte haben ein garantiertes Speicherlayout; Basistypen haben eine Ausrichtung entsprechend ihrer Größe und
bool ist 1 Byte groß
- Pointer- und Funktionstypen sind immer 64 Bit breit und haben dieselbe Ausrichtung
- Arrays haben dieselbe Ausrichtung wie ihre Elemente, und Strukturen enthalten Padding, um die Ausrichtungsanforderungen zu erfüllen
- Diese Garantie dient vor allem dazu, C-Interoperabilität und den Einsatz in der GPU-Programmierung zu vereinfachen
unit und Strukturen ohne Felder werden als leere Typen behandelt, die nur einen einzigen gültigen Wert besitzen; ihre tatsächliche Größe beträgt 0 Byte
- Leere Typen beeinflussen weder den Speicherverbrauch noch die Strukturgröße, selbst wenn sie an Funktionen übergeben, als Variablen deklariert oder als Felder verwendet werden
- Leere Typen können für Dinge wie Typ-Level-Compile-Zeit-Tags verwendet werden
- Lesen/Schreiben über Pointer auf leere Typen ist noch nicht entschieden, und derzeit ist Pointer-Arithmetik auf solchen Typen unzulässig
- Anders als in C++ gilt nicht die Regel, dass jedes Objekt eine eindeutige Speicheradresse haben muss
Variablen, Funktionen, Kontrollfluss, externe Funktionen
- Immutable Variablen werden als
let x = 10, mutable Variablen als mut x = 20 deklariert
- Ein mutabler Pointer auf eine immutable Variable kann nicht erzeugt werden
- Typen können wie in
let x: i32 = 10 explizit angegeben werden, sind aber nicht erforderlich, da die Sprache so entworfen ist, dass sie die Typen aller Ausdrücke eindeutig inferieren kann
- Alle Variablen müssen initialisiert werden
- Funktionen werden als
func foo(x: A, y: B) -> C: mit anschließendem Rumpf geschrieben; wird der Rückgabetyp weggelassen, ist er unit
- Alle Funktionen folgen dem nativen C-ABI der Ausführungsplattform; das ist eine Entscheidung für C-Interoperabilität, Callbacks und die Übergabe als Funktionszeiger an Dinge wie ECS-Systeme
- Innerhalb desselben Scopes ist die Reihenfolge von Funktions- und Strukturdeklarationen frei; Funktionen oder Strukturen, die später deklariert werden, können vorher bereits verwendet werden
- Da alle Funktionsargumente und Rückgabetypen vollständig angegeben werden müssen, macht diese freie Deklarationsreihenfolge die Typinferenz nicht komplizierter
- Es gibt
if/else if/else-Anweisungen und while-Schleifen; for-Schleifen gibt es noch nicht
- Die Ausdrucksform von
if wird wie if A then B else C verwendet
- Externe Funktionen werden wie
foreign func sin(x: f64) -> f64 deklariert; die Implementierung muss an anderer Stelle gelinkt werden
- Der aktuelle Interpreter sucht solche Funktionen per
dlsym in der Interpreter-Binärdatei selbst
- Externe Funktionen sind der wichtigste Mechanismus für die Interoperabilität mit C-Bibliotheken und Third-Party-Libraries; das Raytracer-Beispiel nutzt diese Funktion für Quadratwurzelberechnung, Dateiausgabe, Zeitmessung und Thread-Erzeugung
Type-Casting und Operatoren
- Es gibt überhaupt keine impliziten Type-Casts; für manuelle Casts wird der Operator
as verwendet, etwa (x as f32)
- Alle numerischen Typen können ineinander gecastet werden, ebenso alle Pointer-Typen untereinander, mit Ausnahme der Umwandlung eines immutable Pointers in einen mutable Pointer
- Pointer-Typen können zu
u64 und u64 kann zu Pointer-Typen gecastet werden
bool kann in keinen anderen Typ und aus keinem anderen Typ gecastet werden
- Es wird noch überlegt, einen impliziten Cast von
T mut* nach T* hinzuzufügen
- Standardoperatoren für Arithmetik, Logik, Vergleiche usw. sind größtenteils vorhanden
&, |, &&, || funktionieren sowohl mit Booleans als auch mit Ganzzahlen; & und | werten immer beide Operanden aus, && und || arbeiten mit Short-Circuit-Auswertung
- Arithmetik und Vergleiche funktionieren nur auf Paaren desselben numerischen Typs; es gibt keine Promotion numerischer Typen
- Die Sprachfeatures wirken derzeit vielleicht nicht besonders zahlreich, aber man kann damit bereits einigermaßen komfortabel echte Programme schreiben
Compiler-Struktur
- Das Projekt ist in mehrere Bibliotheken aufgeteilt
types: Definition des Typsystems
ast: Definition des abstrakten Syntaxbaums und Hilfsfunktionen
parser: Parser
ir: Zwischendarstellung
interpreter: Interpreter
jit: JIT-Compiler
- Geplant ist, Interpreter und Compiler als einfache CLI-Apps aufzubauen, die diese Bibliotheken verwenden; derzeit gibt es nur einen Interpreter im JIT-Modus
- Wer die Sprache einbetten will, kann die Bibliotheken
parser und jit verwenden
Parser und Einrückungsverarbeitung
- Als Parser-Generator wird Bison verwendet
- Tokens sind in der lexer grammar, die Sprachgrammatik in der parser grammar definiert
- Eine Datei ist eine Liste von Anweisungen, und eine Anweisung kann eine Funktionsdeklaration, ein Kontrollflussoperator, eine Variablendeklaration, ein Ausdruck usw. sein; Ausdrücke können Literale, Variablen, Operatoren, Funktionsaufrufe usw. sein
- In der Grammatik mussten einige Shift/Reduce-Konflikte behoben werden; mit dem Bison-Flag
-Wcounterexamples wurde geprüft, in welchen exakten Situationen die Konflikte auftreten
- Mit dem Bison-Skelett
lalr1.cc wird eine C++-Parserklasse erzeugt
- Standard-Bison erzeugt einen C-Parser, dessen Parser-Status in globalen Variablen liegt, aber das passt nicht zu Fällen, in denen mehrere Dateien parallel geparst werden müssen, etwa im Interpreter oder im Spielmodus
- Die Ausführung von Bison ist als Build-Schritt in die CMake scripts eingebunden
- Die Ausgabe des Parsers ist ein C++-Objekt, das den AST der geparsten Datei repräsentiert
- Wegen der Einrückung ist die Grammatik in Wirklichkeit nicht kontextfrei, denn ob eine Anweisung zum Rumpf eines
while gehört, hängt von der Anzahl der vorhergehenden Einrückungstokens ab
- Als Lösung wird jede Zeile zunächst als unabhängige Anweisung mit Einrückungsstufe geparst, danach werden die Scopes in einem einfachen linearen Pass anhand der Einrückungsstufen festgelegt
- Dieser Ansatz ist zwar etwas hacky, funktioniert aber, und weil er sehr schnell ist, wird er akzeptiert
- Im selben Pass wird geprüft, dass
break und continue nur innerhalb von Schleifen, return nur innerhalb von Funktionen und Felddefinitionen nur innerhalb von Strukturen vorkommen
Typprüfung und Interpreter
- Der erste Pass nach dem Parsing löst alle Bezeichner auf und verknüpft Bezeichnerknoten direkt mit den entsprechenden Definitionsknoten von Variablen, Funktionen und Strukturen
- Der nächste zentrale Pass prüft und inferiert alle Typen
- Die Typinferenz ist größtenteils simpel und besteht aus Bedingungsprüfungen je nach Typ des jeweiligen AST-Knotens
- Zum Beispiel muss der Typ eines Ausdrucks in
if oder while bool sein, und die beiden Operanden einer Addition müssen denselben numerischen Typ haben oder einer muss ein Integer und der andere ein Pointer sein
- Der anfängliche Interpreter ist ein Tree-Walking-Interpreter, der AST-Knoten direkt besucht und dabei C++-Semantik ausführt
- Die wichtigsten Funktionen sind
exec() und eval(): exec() führt eine einzelne Anweisung aus, und eval() berechnet den Wert eines einzelnen Ausdrucks und gibt ihn zurück
- Da C++ statisch typisiert ist, gibt
eval() ein variant für alle möglichen Werttypen der Sprache zurück
- Strukturen werden als Arrays von Name-Wert-Paaren dargestellt, eines pro Feld, und dasselbe
variant wird auch zum Speichern von Variablenwerten verwendet
- Der Zweck des Interpreters ist es, Sprachcode plattformübergreifend auszuführen und beim Debugging der Implementierung und von Programmen zu helfen; er ist nicht auf Geschwindigkeit ausgelegt
- Der aktuelle Interpreter ist in einem sehr kaputten Zustand, daher ist eine vollständige Neuschreibung auf IR-Basis geplant
- Der bestehende Interpreter kann keine
foreign-Funktionen ausführen
foreign-Funktionen müssen mit der C-Calling-Convention aufgerufen werden, und da Anzahl und Typen der Argumente nicht im Voraus bekannt sind, werden vermutlich Vararg-Techniken oder libffi benötigt
- Der Interpreter kann seinen internen Zustand, also Namen, Typen und Werte von Variablen, nach stdout dumpen; das war vor dem Bau eines echten Compilers die Hauptmethode zum Debuggen von Parser und Interpreter
Der erste Aarch64-JIT-Compiler
- Anfang Januar 2026 hatte ich im Urlaub nur ein M1 Mac dabei, deshalb wurde die erste Zielarchitektur des Compilers Aarch64 auf dem Mac
- Das ist bis heute auch die einzige unterstützte Architektur
- Der Compiler arbeitet als JIT; das Ergebnis ist ein Speicher-Blob, das mit Execute-Bit gemappt ist, plus Pointer auf die Startpunkte der einzelnen Funktionen
- Die High-Level-Struktur ähnelt stark einem traditionellen stackbasierten Compiler, aber Ausdrucksergebnisse werden so platziert, wie sie bei einer Funktion mit demselben Rückgabetyp laut AAPCS64, der Standard-C-Calling-Convention auf Aarch64-Macs, zurückgegeben würden
- Integer und Pointer werden im allgemeinen Register
x0 zurückgegeben, Gleitkommazahlen im Gleitkommaregister v0, und Strukturen werden je nach Größe in Registern oder auf dem Stack zurückgegeben
- Dieser Ansatz reduziert die Zahl der Speicherzugriffe, macht den erzeugten Code schneller und vereinfacht Funktionsaufrufe
- Der Stack wird hauptsächlich für Zwischenergebnisse wie bei binären Operationen verwendet
(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
- Kontrollflussstrukturen werden in bedingte Sprünge umgewandelt, aber bei einer Single-Pass-Kompilierung sind die Ziele dieser Sprünge noch nicht bekannt, weil die Bodies von
if oder while noch nicht kompiliert wurden
- Um das zu lösen, wird zunächst ein Sprungbefehl mit Offset 0 ausgegeben; sobald der Ziel-Offset bekannt ist, wird der echte Sprung-Offset hineingepatcht
- Dasselbe Verfahren wird auch bei Funktionsaufrufen verwendet
- Für die Erzeugung der Ziel-CPU-Instruktionen wird keine Third-Party-Bibliothek verwendet; um den Compiler klein zu halten, wurde das direkt implementiert
- Die Implementierung bestand darin, das Instruction Manual zu durchforsten und die benötigten Bits einzutragen
Knifflige Punkte bei Aarch64
- Alle Instruktionen in Aarch64 sind 32 Bit breit, was zunächst einfach wirkt, aber um eine 32-Bit-Konstante in ein Register zu laden, braucht man Registerauswahlbits, Instruktionsbits und Konstantenbits zugleich, was nicht in einen einzelnen 32-Bit-Befehl passt
- 64-Bit-Konstanten sind ein noch größeres Problem
- Konstanten müssen entweder aus 16-Bit-Stücken zusammengesetzt werden, die an die Bitpositionen 0, 16, 32 und 48 geladen werden, oder sie müssen in einem Konstantenspeicher abgelegt und von dort geladen werden
- Für Gleitkommakonstanten wird der Weg über Konstantenspeicher verwendet
- Anders als bei x86 gibt es keine Push-/Pop-Instruktionen; stattdessen muss man Instruktionen kombinieren, die zwischen Registern und Speicheradressen lesen bzw. schreiben und dabei das Adressregister anpassen
- Weil alle Instruktionen exakt 32 Bit breit sind, muss man ständig darauf achten, ob ein Offset signed oder unsigned ist, ob er vorab mit einer bestimmten Konstante multipliziert wird und ob das Adressregister verändert wird
- Wenn auf dem Stack relativ zum SP-Register gelesen und geschrieben wird, muss der Stack-Pointer immer auf 16 Byte ausgerichtet sein
- Die möglichen Offsets sind auf 12 Bit begrenzt, daher ist bei Stack-Frames von ungefähr mehr als 16 KB spezieller Code nötig, der aber noch nicht implementiert ist
- Die Calling-Convention enthält Sonderfälle, bei denen Strukturen über bis zu zwei allgemeine Register, Gleitkommaregister oder einen Speicherpointer übergeben bzw. zurückgegeben werden, und der Compilercode muss das abdecken
Einführung von IR und der zweite Compiler
- Nach dem Bau des grundlegenden Interpreters und Compilers wurde eine Zwischendarstellung (IR) eingeführt, um Code wiederzuverwenden, das Schreiben von Compilern für andere Architekturen zu vereinfachen und Optimierungen zu ermöglichen
- Die IR begann ähnlich wie SSA, aber da Werte demselben Knoten neu zugewiesen werden können und keine Phi-Knoten verwendet werden, ist sie tatsächlich kein SSA
- Die IR ist eine Sequenz von Knoten; jeder Knoten steht für ein Literal, eine Operation mit Eingabeknoten, einen bedingten oder unbedingten Sprung, einen Funktionsaufruf usw.
- Knoten, die Werte darstellen, speichern auch den Typ dieses Werts
- Weil Neuzuweisungen erlaubt sind, gibt es eine
assign-IR-Instruktion, die einem bestehenden Knotenwert erneut etwas zuweist
- Bedingte Sprünge sind in
jump_if_zero und jump_if_nonzero aufgeteilt; das entspricht meist unterschiedlichen CPU-Instruktionen und ist schneller, als erst den Wert zu negieren und dann die Gegeninstruktion zu verwenden
- Da Funktionspointer unterstützt werden, gibt es getrennte Instruktionen für den Aufruf eines bekannten IR-Knotens und für den Aufruf eines unbekannten Pointer-Werts
- Damit sich bei Optimierungen Knoten an beliebigen Stellen leicht entfernen oder einfügen lassen, werden die Knoten in
std::list gespeichert und Referenzen als Listen-Iteratoren gehalten
- Literale Strukturwerte können nicht erzeugt werden; stattdessen gibt es einen
alloc-Knoten für Strukturwerte, der typischerweise in eine Allokation von nicht initialisiertem Strukturplatz auf dem Stack kompiliert wird
- Strukturen werden aufgebaut, indem einzelnen Feldern Werte zugewiesen werden
- Wenn man ein verschachteltes Strukturfeld wie
a.x.y naiv darstellt, würde a.x als neuer Knoten gelesen und dann y dieses Knotens gelesen, was viel Verschwendung verursacht
- Auch
a.x.y = b wäre ineffizient, wenn es als t = a.x, t.y = b, a.x = t dargestellt würde; deshalb behandelt die IR verschachtelte Felder speziell
- Ein
copy-Knoten kann ein beliebiges verschachteltes Feld aus einer Struktur extrahieren, und ein assign-Knoten kann einem beliebigen verschachtelten Feld einer Struktur einen Wert zuweisen
- Verschachtelte Felder werden als Array von Indizes dargestellt, etwa im Sinn von „nimm Feld 0, dann darin Feld 2, dann darin Feld 5“
- Danach wurde der Aarch64-Compiler neu geschrieben und in einen AST→IR-Compiler und einen IR→Aarch64-Compiler aufgeteilt
- AST → IR ist vergleichsweise einfach, aber der IR → Aarch64-Compiler ist derzeit in einem deutlich schlechteren Zustand als der frühere stackbasierte Compiler
- Zu Beginn einer Funktion wird so viel Stack-Speicher reserviert, wie für alle IR-Knoten dieser Funktion nötig ist; dadurch belegen auch die meisten nur kurzlebigen Zwischenwerte Platz im Stack-Frame
- Eine Funktion im Raytracer musste in zwei Teile aufgespalten werden, damit ihr Stack-Frame noch innerhalb der zuvor erwähnten 12-Bit-Grenze blieb
- Dieser Compiler setzt den Einsatz eines Register-Allocators voraus, daher wird erwartet, dass sich der erzeugte Code später um mehrere Größenordnungen verbessert
Pläne für Compiler und Interpreter
- Die aktuelle Implementierung besteht aus etwa 10.000 Zeilen C++-Code, und ich bin zufrieden damit, dass der Compiler nach heutigen Maßstäben klein ist und tatsächlich funktioniert.
-
Register-Allocator
- Der aktuelle IR→Aarch64-Compiler braucht unbedingt einen Register-Allocator.
- Geplant ist ein standardmäßiger Linear-Scan-Allocator als Kompromiss zwischen Compile-Geschwindigkeit und Codequalität.
-
IR-Optimierung
- Auf Basis des IR sollen Constant Propagation, arithmetische Vereinfachung, Dead Code Elimination, Inlining und Loop Unrolling hinzukommen.
- Das Ziel ist nicht, GCC oder LLVM zu schlagen, aber einfache Funktionen wie 3D-Vektoraddition sollen mit möglichst wenigen CPU-Instruktionen kompiliert werden.
-
IR-Interpreter
- Der Interpreter soll so umgeschrieben werden, dass er das IR direkt auswertet; dadurch dürfte er erheblich einfacher werden.
-
Erzeugung von ausführbaren Dateien
- Der aktuelle Compiler erzeugt nur einen JIT-Speicher-Blob zur sofortigen Ausführung.
- Es sollen auch ausführbare Binärdateien in plattformspezifischen Formaten erzeugt werden; dafür muss ich mich in Binärformat-Spezifikationen wie ELF, Mach-O und PE einarbeiten.
- Eines der Ziele ist auch, möglichst kleine ausführbare Dateien zu erzeugen.
-
Debugging
- Ich habe die vom JIT erzeugte Assembly oft in lldb verfolgt und möchte die Sprache selbst ordentlich debuggen können.
- Dafür wird sehr wahrscheinlich Unterstützung für das DWARF-Debug-Informationsformat nötig sein, von dem ich derzeit fast nichts weiß.
Sprachfunktionen, die ich hinzufügen möchte
-
Struct-Konstruktoren
- Derzeit können Structs nur so verwendet werden, dass entweder alle Felder gesetzt werden wie in
vec3i(1, 2, 3) oder alles wie in vec3i() auf 0 initialisiert wird.
- Ich erwäge, dass eine Funktion mit demselben Namen wie das Struct als beliebiger Konstruktor fungiert.
func vec3i(x: i32, y: i32) -> vec3i:
return vec3i(x, y, 0)
- Allerdings ist noch nicht sicher, ob es nicht besser wäre, solchen Funktionen eigene Namen zu geben.
-
Globale Variablen
- Derzeit werden globale Variablen nicht unterstützt.
- Geplant sind globale Variablen mit dem Schlüsselwort
global; der Zugriff unterliegt aber weiterhin den Regeln des Gültigkeitsbereichs, sodass man ähnlich wie bei static-Variablen in C funktionslokale globale Variablen haben kann.
- Variablen auf oberster Ebene sind keine echten Globals, sofern nicht
global verwendet wird, sondern lokale Variablen der Dateieinstiegspunkt-Funktion.
- Diese Struktur könnte für Nutzer verwirrend sein, daher ziehe ich auch andere Optionen in Betracht.
- Da macOS Speicher-Mappings nicht gleichzeitig beschreibbar und ausführbar erlaubt, müssen globale Variablen möglicherweise getrennt vom Code allokiert und mit anderen Flags gemappt werden.
- Auf globale Variablen muss möglicherweise über zur Laufzeit aufgelöste Adressen statt über zur Compile-Zeit bekannte Offsets zugegriffen werden.
- Es scheint jedoch möglich zu sein, die Flags eines Teils eines Mappings mit
mprotect() zu ändern, also will ich das zuerst ausprobieren.
-
Method-Call-Syntax
- Für bessere Lesbarkeit möchte ich, dass
x.f(y) dort, wo es möglich ist, f(&x, y) oder f(&mut x, y) bedeutet.
-
Polymorphismus
- Das halte ich für die wichtigste potenzielle Funktion.
- Wahrscheinliche Optionen sind Funktionsüberladung im C++-Stil und uneingeschränkte Funktionstemplates/Struct-Templates oder explizite Traits im Haskell-/Rust-Stil mit trait-beschränkten generischen Funktionen/Structs.
- Der C++-Stil ist mächtiger, in einfachen Fällen leichter zu lesen und auch einfacher im Compiler zu implementieren, aber Fehlermeldungen können extrem schwer verständlich werden.
- Explizite Traits sind in manchen Fällen leichter zu lesen und lösen das Problem mit den Fehlermeldungen, erfordern aber mit Traits und Trait Bounds ein neues System und machen den Compiler schwieriger zu implementieren.
- Ich habe mich noch nicht entschieden, neige aber stark zur ersten Option, obwohl ich eigentlich kein C++ nachbauen wollte.
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
- Wenn möglich, möchte ich auch Argumentinferenz für Funktionen.
-
Operator Overloading
- Dafür wird in irgendeiner Form Polymorphismus gebraucht.
a + b könnte dann einen überladenen Funktionsaufruf wie add(a, b) oder eine Trait-Methode wie Add::add bedeuten.
-
for-Schleifen
- Da man sie mit
while nachbilden kann, soll for als kollektivbasierte Schleife wie die range-based loops in C++ oder Schleifen in Python verwendet werden.
- Dafür braucht es ein Range-/Iterator-Interface und damit wiederum Polymorphismus.
-
Automatisches Ressourcenmanagement
- Ich denke, eine praktische und gut nutzbare Sprache braucht eine Möglichkeit, beim Freigeben von Ressourcen wie Speicher, Dateien, Sockets und Mutexen zu helfen.
- Kandidaten sind RAII und Move-Semantik im C++-Stil,
defer im Zig-Stil und lineare Typen.
- RAII hat den Nachteil, implizit zu sein und versteckte Anweisungen sowie Kontrollfluss hinzuzufügen.
defer ist explizit, muss aber jedes Mal von Hand eingefügt werden, verhindert kein Vergessen und ist unpraktisch, wenn verschachtelte Collections wie ein Datei-Array freigegeben werden müssen.
defer free(array)
defer for file in array:
close(file)
- Lineare Typen wirken vielversprechend, weil sie die Explizitheit manueller Aufrufe wie
free oder close beibehalten und zugleich erzwingen können, dass Objekte von Ressourcenfreigabefunktionen konsumiert werden.
- Allerdings ist noch nicht entschieden, weil sie sich nur schwer mit verschachtelten Collections wie dynamischen Datei-Arrays kombinieren lassen.
-
Polymorphe Literale
- Bei einem leeren Array
[] kennt man zwar die Größe 0, kann aber den Elementtyp nicht ableiten.
null könnte jeder Zeigertyp sein, und das Literale inf, das ich hinzufügen möchte, könnte jeder Gleitkommatyp sein.
- Als Lösungen erwäge ich Haskell-artige polymorphe Literale, spezielle eingebaute/Bibliothekstypen mit impliziten Konvertierungen wie
nullptr_t in C++ oder spezielle Literale im AST mit ad-hoc-Behandlung im Compiler.
- Im Moment neige ich zur letzten Variante, bei der
null nur an Stellen erlaubt ist, an denen der erwartete Zeigertyp bekannt ist, etwa bei der Initialisierung einer explizit typisierten Variable oder beim Übergeben an eine Funktion.
- Diese Variante ist am einfachsten, aber nicht erweiterbar, sodass man keine benutzerdefinierten Typen aus
null erzeugen kann.
-
Compile-Time-Auswertung
- Ich möchte mit dem Schlüsselwort
const Compile-Time-Variablen deklarieren, die in Compile-Time-Ausdrücken wie Array-Größen verwendet werden können.
const-Werte können nicht neu zugewiesen werden, und ihre Adresse kann nicht genommen werden.
- Geeignete Funktionen sollen in Compile-Time-Ausdrücken aufrufbar sein, wenn sie nicht auf globale Variablen zugreifen und keine Seiteneffekte haben.
- Der Funktionskörper verhält sich wie eine normale Funktion, wird aber während des Kompilierens ausgeführt und sein Ergebnis wird zu einem Compile-Time-Ausdruck.
- Es wird einen Mechanismus brauchen, um
foreign-Funktionen zu kennzeichnen, die auch zur Compile-Time sicher aufgerufen werden können, etwa mathematische Funktionen oder Speicherallokation.
-
Typberechnung
- Für Metaprogrammierung möchte ich Berechnungen über Typen unterstützen.
- Da ich in einer statisch typisierten Sprache keine Laufzeit-Typkodierung aufbauen möchte und Laufzeittypen ohnehin nur begrenzten Nutzen haben, ist das ausschließlich für die Compile-Time geplant.
- Etwas Ähnliches wie C++ Concepts könnte sich dann vermutlich auch ohne eigene Syntax als Compile-Time-Aufruf umsetzen lassen.
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
- Das Hinzufügen von
async/await im Python- oder JS-Stil ist eher ein Wunsch als ein konkreter Plan.
Bibliotheks- und Modulplanung
-
Module
- Es ist unrealistisch, den gesamten Code in eine einzige Datei zu schreiben, daher werden Module benötigt.
- Geplant ist eine einfache Anweisung wie
import lib.sublib, die überall im Code stehen kann und ebenfalls den Scope-Regeln folgt.
- Der Scope beeinflusst nur die Sichtbarkeit; das eigentliche Laden erfolgt zur Compile-Zeit, und der Entry Point des importierten Moduls wird vor dem aktuellen Modul ausgeführt.
- Bibliotheksnamen entsprechen direkt Dateisystempfaden relativ zu einem Root-Pfad, der dem Compiler oder Interpreter angegeben wird.
- Bei einer einzelnen Quelldatei wird nur diese Datei importiert, bei einem Verzeichnis werden alle Dateien darin in irgendeiner Reihenfolge importiert.
- Es wird eine Syntax benötigt, um Dateien im selben Verzeichnis zu referenzieren; erwogen wird eine Form wie
import .another.
- Importierte Funktionen und globale Variablen können ohne Präfix verwendet werden; bei Mehrdeutigkeit kann ein Präfix mit dem Bibliotheksnamen verwendet werden, etwa
io.print(x).
- Die Entry Points der Module sollen in einer deterministischen Reihenfolge ausgeführt werden, die sich aus der Import-Reihenfolge und einer topologischen Sortierung rekursiver Imports ergibt; damit ließe sich das Problem der Initialisierungsreihenfolge in C oder C++ lösen.
- Das Speicherlayout von Programmen mit mehreren Modulen ist noch nicht entschieden.
- Man könnte für jedes Modul einen eigenen Speicher-Patch vorsehen und Funktionsaufrufe sowie globale Variablenzugriffe zur Laufzeit auflösen, oder alles in ein einziges großes Memory-Mapping legen und relative Offsets verwenden.
- Ein einziges großes Mapping kann zur Laufzeit schneller sein, erschwert aber die parallele Kompilierung mehrerer Module.
-
Prelude
- Mit Modulen kann man grundlegende Hilfsfunktionen in ein Prelude-Modul legen, das implizit in jedes Programm eingebunden wird.
- Kandidaten sind eine
length()-Funktion für eingebaute Arrays, ein Iterator-Interface, ein string_view-Typ und numerische Ranges wie Pythons range(n).
-
String-Literale
- String-Literale gibt es noch nicht, und es ist noch nicht entschieden, welche Semantik sie haben sollen.
- Geplant ist, im Prelude einen unveränderlichen
string_view-Typ bereitzustellen, den String-Inhalt irgendwo im ausführbaren Speicher abzulegen und das Literal selbst in einen string_view umzuwandeln, der auf diesen Speicher zeigt.
-
Standardbibliothek
- Sobald es Module gibt, wird auch eine Standardbibliothek nötig.
- Der gewünschte Umfang umfasst eine Mathematikbibliothek mit Vektoren und Matrizen, Speicherverwaltung in Form von aus
libc gelinktem alloc/free, dynamische Arrays, dynamische Strings und Formatting, Hash-Tabellen, Konsolen- und Datei-IO, Dateisystem-Helfer, Zeit- und Uhren-Helfer sowie Networking.
Aktuelle Prioritäten
- Es ist noch nicht entschieden, wann die geplanten Funktionen implementiert werden oder ob diese Sprache tatsächlich für Game-Modding oder andere Zwecke verwendet wird.
- Der Autor hält es nicht für sinnvoll, mehrere ambitionierte Projekte gleichzeitig ernsthaft voranzutreiben, und die aktuelle Priorität bleibt weiterhin die Spieleentwicklung.
- Da man ein Spiel erst modden kann, wenn es überhaupt existiert, wird an der Sprache momentan nur dann gearbeitet, wenn gerade Lust darauf besteht.
1 Kommentare
Lobste.rs-Meinungen
Die Kommentare hier wirken deutlich härter, als ich es von dieser Community erwartet hätte.
Es ist gut möglich, dass etwas wie Lua oder eine andere Sprache völlig ausgereicht hätte. Es ist auch möglich, dass der Autor in ein riesiges Yak Shaving geraten ist.
Trotzdem ist klar, dass er sehr kompetent ist und großen Spaß daran hat, und der Text enthält auch interessante technische Inhalte.
Wenn es ein Text von einem anderen Nerd ist, der wieder eine Skriptsprache für eine Game Engine entwirft, lese ich das gern mit Vergnügen. Wenn ich dafür einen KI-generierten Fülltext darüber vermeiden kann, wie irgendein mit Vibecoding gebauter SaaS-Müll die Welt rettet und den Autor reich macht, dann könnte ich von solchen Texten tausend am Tag lesen.
Die Aussage „Lua oder eine andere JIT-kompilierte Skriptsprache ist die Standardwahl, aber Sandboxing ist wirklich schwer“ ist wirklich schwer nachzuvollziehen.
Dass Sandboxing in Lua einfach ist, ist einer seiner größten Vorteile und bringt nicht nur für Mods oder Plugins große Pluspunkte. Keine Sprache, die ich gesehen habe, kommt da auch nur annähernd heran.
Das Thema Lua-Versionen ist teilweise ein berechtigter Punkt, aber in der Praxis habe ich kaum erlebt, dass Leute sich darüber ernsthaft aufregen. Wenn man nicht „modernes“ Lua für einen Zweck benutzt und wegen einer anderen Aufgabe wieder auf 5.1/5.2 zurückmuss, scheinen die meisten ohnehin einfach bei einem von beiden zu bleiben.
Es wirkt stark so, als sei recherchiert worden, um „ich will meine eigene Sprache bauen“ zu rechtfertigen. Das an sich ist okay, aber dann wäre Ehrlichkeit besser, als über bestehende Optionen völlig falsche Behauptungen aufzustellen.
Wenn man sich für VM-Design oder tieferliegende Ebenen interessiert, ist der im Text beschriebene Weg natürlich ebenfalls möglich. Aber mit dem besten Weg, Sprachdesign zu lernen, hat das nur wenig zu tun.
Das einfachste Beispiel ist ein Bytecode-Escape. Wenn man weiß, dass es das gibt, kann man es deaktivieren, aber dass so etwas immer wieder passiert, zeigt ein breiteres Problem. Man muss verstehen, wie voneinander getrennte Teile der Lua-Spezifikation miteinander interagieren, um sich Sandboxing-Regeln zusammenzubauen; es ist keine Struktur, in der man Programme sicher aus klaren Grundbausteinen zusammensetzen kann und dabei eindeutig weiß, welche zusätzlichen Interaktionen man zulässt.
Ein noch erzwungeneres Beispiel ist Prototype Pollution zwischen verschiedenen Umgebungen innerhalb derselben Lua-VM. In Redis konnte man die Metatable von
stringvergiften, und dann Code mit den Rechten anderer Datenbanknutzer ausführen, die Lua-Funktionen verwendeten. Lua hat im Vergleich zu etwas wie JavaScript eine astronomisch kleinere Angriffsfläche für Prototype Pollution, aber es ist schon komisch, dass es grob nur zwei globale Prototypen gibt und man mit einem davon im Grunde dasselbe tun kann.Trotzdem hat Luau dafür eine ziemlich kompetente Lösung, und ich verstehe nicht recht, warum der Autor glaubt, dass er automatisch all diese Probleme vermeidet, wenn er eine neue Sandbox baut.
Die Stelle „Mein Spiel ist sehr simulationslastig. Ich simuliere Hunderttausende Entitäten in einer Custom-ECS-Engine. Im Idealfall sollte die Modding-Sprache mehrere Komponenten-Pointer annehmen und wie eine
for-Schleife in C darüber iterieren können“ könnte ein besseres Ideal haben.Insbesondere wäre es sinnvoll, zu vergleichen, wie Rendering-Engines wie Unity, Unreal, Blender und Godot dieses Problem angehen. Externe Iteration ist nicht schnell genug, um über Megapixel pro Sekunde zu sprechen, und könnte auch für Hunderttausende Entitäten pro Sekunde ungeeignet sein. Hier muss man über Parallelität nachdenken.
Große Engines sind alle GPU-freundlich und verwenden meist Datenfluss-Beschreibungen von verzweigungsfreien Algorithmen, die erschreckend gut parallelisierbar sind. Der Autor mag visuelle Editoren nicht, und diese Haltung ist auch verbreitet, aber das bedeutet nicht, dass
for-Schleifen die Antwort sind.Wenn der Autor erwähnt hätte, dass ECS im Kern ein relationales Paradigma ist und dass die historisch belastete Sprache, mit der man es vergleichen sollte, SQL ist, wäre ich vielleicht wohlwollender gewesen.