1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • pslang nació del interés por la capacidad de modding en juegos grandes y por el ensamblador que generan los compiladores de C++, y hoy ya funciona lo suficiente como para escribir un path tracer Monte Carlo de unas 1,000 LOC
  • Un lenguaje de modding necesita interoperabilidad con C, manejo de arreglos y punteros de bajo nivel, sandboxing sencillo, un compilador pequeño y compilación rápida; Lua y el modo nativo en C++ muestran limitaciones de rendimiento, integración, sandboxing y distribución, respectivamente
  • pslang es un lenguaje de bajo nivel basado en imperativo, evaluación estricta y paso por valor, con un sistema de tipos estático, estricto y nominal, alcance basado en indentación, arreglos integrados, tipos de función, punteros y disposición de memoria garantizada
  • El compilador se divide en un parser basado en Bison, verificación de tipos sobre AST, IR, intérprete y JIT; por ahora solo soporta Aarch64 Mac, y tras la introducción del IR la calidad del código generado sigue siendo baja por la falta de un asignador de registros
  • La implementación actual tiene unas 10,000 líneas de código en C++, y se están considerando funciones como asignador de registros, optimización de IR, intérprete de IR, generación de ejecutables, información de depuración, polimorfismo, módulos y biblioteca estándar

Antecedentes de la creación de pslang

  • Después de unos 17 años programando, creció el deseo de crear personalmente un lenguaje que no fuera un simple juguete, sino algo pensado en cierta medida para uso real
  • En el pasado se hicieron intérpretes de lenguajes esotéricos como FALSE y varios intérpretes de cálculo lambda, pero eso no satisfacía el deseo de crear un lenguaje “de verdad”
  • Como el juego grande en desarrollo tiene una estructura apta para modding, al pensar cómo implementarlo apareció un lenguaje de programación personalizado como una de las soluciones más simples
  • En diciembre de 2025, al ver Advent of Compiler Optimisations de Matt Godbolt, surgió el interés por seguir el ensamblador que generan los compiladores de C++, y volvió la idea de trabajar otra vez con ensamblador
  • El lenguaje actual está lejos de tener calidad de producción, pero ya se ha implementado hasta el punto de poder escribir un path tracer Monte Carlo funcional de unas 1,000 LOC

Requisitos de modding y límites de las opciones existentes

  • El juego simula cientos de miles de entidades con un motor ECS personalizado, así que se quiere que el lenguaje de modding pueda recibir grupos de punteros a componentes y recorrerlos como en un bucle for de C
  • Como los mods son difíciles de controlar, el sandboxing debe ser sencillo para proteger a los jugadores, e idealmente debería ser posible desactivar todo IO y funciones similares con un solo switch
  • El modding debe ser tan fácil que baste con poner scripts en una carpeta específica para usarlos de inmediato como mods
  • Lua y los lenguajes de scripting con JIT

    • Lua es la opción estándar, pero parece requerir sandboxing con preprocesamiento, como agregar código que elimine las funciones relacionadas con IO de la biblioteca estándar antes de ejecutar código no confiable, y eso no se siente como una solución estable
    • Lua es un lenguaje dinámico y de alto nivel, así que no entiende directamente punteros de C; para conectar el recorrido de entidades en ECS, habría que hacer la transición native ↔ Lua ↔ native para cada entidad, o convertir entidades nativas en arreglos de Lua y luego desarmarlos otra vez
    • Lua estándar y LuaJIT se han separado desde hace varias versiones, lo que puede generar confusión tanto para modders como para implementadores
  • C++ y mods nativos

    • Si los mods se hacen en C++, el problema del recorrido de entidades desaparece, pero distribuir binarios exige entornos de desarrollo para todas las plataformas y un repositorio de artefactos binarios
    • Si se distribuyen como código fuente, habría que incluir un compilador de C++ en el juego, y una instalación base de LLVM ocupa actualmente entre 10 y 20 veces más espacio en disco que el propio juego
    • Si un DLL nativo declara y usa int open();, en la práctica es imposible bloquear el acceso al sistema de archivos o a la red, así que el sandboxing no es viable
    • El mismo problema aplica a otros lenguajes nativos como Rust
    • El modding es uno de los objetivos, pero todavía no está claro si este lenguaje se usará realmente para modding de juegos, y no se quiere especializarlo en exceso para un caso de uso concreto

Objetivos de diseño del lenguaje

  • Se busca ofrecer interoperabilidad con C sin fricción, para que la conexión entre el código nativo del juego y el código de modding sea tan simple como una llamada de función
  • Como hay que manejar arreglos crudos de entidades, se necesitan capacidades de bajo nivel
  • Debe ser práctico y cómodo de usar para que los modders puedan escribir código con una comodidad razonable
  • El sandboxing debe ser sencillo, y el compilador también debe ser pequeño
  • No se quiere meter un compilador de 1 GB dentro de un juego de 50 MB, así que se busca reducir la huella del compilador
  • Se necesita compilación rápida para que los jugadores no esperen demasiado al compilar mods, aunque parte de esto puede mitigarse con caching amplio
  • Se quiere una verdadera capacidad multiplataforma, aunque se aceptan supuestos como unas cuantas plataformas de escritorio comunes, 64 bits y soporte para IEEE754
  • Basta con que sea razonablemente rápido en comparación con la mayoría de los lenguajes dinámicos
  • Como C++ fue durante mucho tiempo el lenguaje principal, eso influyó mucho en la visión del lenguaje, pero se intenta no recrear simplemente C++

Modelo actual del lenguaje pslang

  • El nombre provisional es pslang, tomado del motor de juego psemek, y es un lenguaje imperativo, de evaluación estricta, paso por valor y de bajo nivel
  • El sistema de tipos es estático, estricto y nominal
  • El ejemplo básico usa funciones, estructuras, tipos de función y devolución de arreglos al mismo tiempo
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]

Alcance y tipos básicos

  • Usa alcance basado en indentación para que se vea como un lenguaje de scripting y resulte más amigable para principiantes
  • Actualmente la indentación usa tabulaciones, aunque más adelante podría cambiar a espacios
  • Los cuerpos de funciones, bucles, bloques if, etc. crean nuevos alcances, y las funciones y estructuras pueden definirse dentro de cualquier alcance, siendo visibles solo dentro de ese alcance
  • Las funciones locales no pueden acceder a variables del alcance donde fueron definidas, así que no son closures; el alcance solo afecta la resolución de nombres
  • El alcance de nivel superior se trata como cualquier otro alcance e incluye el punto de entrada que se ejecuta al cargar o inicializar el archivo
  • Los tipos básicos son bool, 4 enteros con signo, 4 enteros sin signo, 3 tipos de punto flotante y unit, para un total de 13
i8  i16  i32  i64
u8  u16  u32  u64
    f16  f32  f64
  • f8 no se incluye porque la mayoría de las CPU de escritorio no lo soportan y tampoco hay consenso sobre el significado del punto flotante de 8 bits
  • f16 es menos útil para usuarios generales, pero se usa con frecuencia en gráficos, como en color HDR y atributos de vértices, y como la mayoría de las CPU modernas de escritorio implementan f16 de IEEE754, se ofrece soporte por defecto
  • Toda la aritmética entera usa complemento a dos con overflow, y no hay comportamiento indefinido
  • unit solo tiene un valor, unit(), y es el tipo de retorno formal para funciones sin valor de retorno
  • Las funciones que omiten el tipo de retorno devuelven automáticamente unit, y si se omite return al final de una función así, se inserta automáticamente
  • Si una función que no es de tipo unit no devuelve un valor, eso es un error

Literales, arreglos, tipos de función y punteros

  • El número 10 es i32 por defecto, y su tamaño se especifica con sufijos como 10b, 10s, 10l
  • Los literales sin signo usan el sufijo u, y se escriben como 10ub, 10us, 10u, 10ul
  • Los literales de punto flotante con decimal son f32 por defecto; 10.0h es de 16 bits y 10.0d es de 64 bits
  • No se puede omitir la parte entera o la parte decimal como en 10. o .5; deben escribirse completas, como 10.0 y 0.5
  • Todos los literales numéricos tienen un tipo no ambiguo
  • Los arreglos son un tipo integrado de primera clase y, a diferencia de C/C++, se puede pasar el arreglo completo a una función, devolverlo o asignarlo entre sí
  • El tamaño del arreglo siempre se conoce en tiempo de compilación, y se comporta como una estructura con varios campos del mismo tipo
  • El tipo de arreglo se escribe como i32[5], y un literal de arreglo como [1, 2, 3, 4, 5]
  • Los tipos de función se parecen a los punteros a función de C, se escriben con la forma (a, b, c) -> d, y si hay un solo argumento se pueden omitir los paréntesis, como en a -> b
  • Internamente, un tipo de función es un puntero a función normal al que no se le pasan datos adicionales, y no es un closure
  • Los tipos de puntero se escriben como i32*; por defecto son punteros inmutables, y los punteros mutables se declaran como i32 mut*
  • La dirección de una variable se obtiene con &x, un puntero mutable con &mut x, la desreferenciación con *p, y la aritmética de punteros se usa como *(p + 10)

Estructuras, disposición en memoria y tipos vacíos

  • Las estructuras se declaran con la palabra clave struct y una lista de campos
struct string_view:
    size: u64
    data: u8*
  • Las estructuras se crean con un constructor funcional integrado como string_view(10, data), y se accede a sus campos con punto, como v.x
  • También se puede acceder a los campos de un puntero a estructura con la misma sintaxis de punto
  • Los campos de una estructura no tienen un especificador de mutabilidad separado; los campos de un objeto mutable son mutables y los de un objeto inmutable son inmutables
  • No hay modificadores de acceso y los campos siempre son públicos
  • Todos los objetos tienen una disposición en memoria garantizada; los tipos básicos tienen una alineación igual a su tamaño y bool ocupa 1 byte
  • Los punteros y los tipos de función siempre son de 64 bits y tienen la misma alineación
  • Los arreglos tienen la misma alineación que sus elementos, y las estructuras incluyen padding para cumplir los requisitos de alineación
  • Esta garantía existe principalmente para simplificar la interoperabilidad con C y el uso en programación para GPU
  • unit y las estructuras sin campos se tratan como tipos vacíos, que solo tienen un único valor válido, y su tamaño real es de 0 bytes
  • Pasar un tipo vacío a una función, declararlo como variable o ponerlo como campo no afecta el uso de memoria ni el tamaño de la estructura
  • Los tipos vacíos pueden usarse como etiquetas de tiempo de compilación a nivel de tipo
  • La lectura/escritura a través de punteros a tipos vacíos todavía no está definida, y por ahora la aritmética de punteros sobre esos tipos es ilegal
  • No sigue la regla de C++ según la cual cada objeto tiene una dirección de memoria única

Variables, funciones, flujo de control y funciones externas

  • Las variables inmutables se declaran como let x = 10, y las mutables como mut x = 20
  • No se puede crear un puntero mutable a una variable inmutable
  • Se puede indicar el tipo explícitamente, como en let x: i32 = 10, pero no es obligatorio porque el lenguaje está diseñado para inferir sin ambigüedad el tipo de todas las expresiones
  • Todas las variables deben inicializarse obligatoriamente
  • Las funciones se escriben como func foo(x: A, y: B) -> C: seguido del cuerpo, y si se omite el tipo de retorno, este es unit
  • Todas las funciones siguen el ABI nativo de C de la plataforma de ejecución; esto se decidió para la interoperabilidad con C, callbacks y para poder pasarlas como punteros a función en sistemas como ECS
  • Dentro del mismo scope, el orden de las declaraciones de funciones y estructuras es libre, así que se puede usar antes una función o estructura declarada después
  • Como todos los argumentos de función y tipos de retorno deben declararse por completo, esta libertad en el orden de declaración no complica la inferencia de tipos
  • Existen sentencias if/else if/else y bucles while, pero todavía no hay bucles for
  • La forma de expresión de if se usa como if A then B else C
  • Las funciones externas se declaran como foreign func sin(x: f64) -> f64, y su implementación debe enlazarse desde otro lugar
  • Actualmente el intérprete busca esas funciones en el propio ejecutable del intérprete usando dlsym
  • Las funciones externas son el mecanismo principal de interoperabilidad con bibliotecas de C y de terceros, y el ejemplo del raytracer usa esta función para calcular raíces cuadradas, escribir archivos, medir tiempo y crear hilos

Conversiones de tipo y operadores

  • No hay conversiones implícitas de tipo en absoluto; las conversiones manuales usan el operador as, como en (x as f32)
  • Todos los tipos numéricos pueden convertirse entre sí, y todos los tipos de puntero también, excepto convertir un puntero inmutable en uno mutable
  • Un tipo de puntero puede convertirse a u64, y u64 puede convertirse a un tipo de puntero
  • bool no puede convertirse a ningún otro tipo
  • Se está considerando agregar una conversión implícita de T mut* a T*
  • En general están disponibles los operadores estándar de aritmética, lógica, comparación, etc.
  • &, |, &&, || funcionan tanto con booleanos como con enteros; & y | siempre evalúan ambos operandos, mientras que && y || usan evaluación de cortocircuito
  • Las operaciones aritméticas y comparaciones solo funcionan entre pares del mismo tipo numérico; no hay promoción de tipos numéricos
  • Puede parecer que el lenguaje todavía no tiene muchas funciones, pero ya permite escribir programas reales con bastante comodidad

Estructura del compilador

  • El proyecto está dividido en varias bibliotecas
    • types: definición del sistema de tipos
    • ast: definición del árbol de sintaxis abstracta y utilidades
    • parser: parser
    • ir: representación intermedia
    • interpreter: intérprete
    • jit: compilador JIT
  • La idea es mantener el intérprete y el compilador como aplicaciones CLI simples que usan estas bibliotecas; por ahora solo existe un intérprete en modo JIT
  • Para integrar el lenguaje, basta con usar las bibliotecas parser y jit

El parser y el manejo de la indentación

  • Se usa Bison como generador de parser
  • Los tokens están definidos en la lexer grammar, y la gramática del lenguaje en la parser grammar
  • Un archivo es una lista de sentencias, y una sentencia puede ser una declaración de función, un operador de flujo de control, una declaración de variable, una expresión, etc.; una expresión puede ser un literal, una variable, un operador, una llamada a función, etc.
  • Hubo que corregir varias veces conflictos shift/reduce en la gramática, y se usó la bandera -Wcounterexamples de Bison para ver la situación exacta que causaba el conflicto
  • Se usa el esqueleto lalr1.cc de Bison para generar una clase de parser en C++
  • El Bison predeterminado genera un parser en C cuyo estado se guarda en variables globales, pero eso no encaja cuando se necesita poder parsear varios archivos en paralelo, como en el intérprete o en el modo juego
  • La ejecución de Bison se integró como una etapa de compilación en los scripts de CMake
  • La salida del parser es un objeto de C++ que representa el AST del archivo parseado
  • Debido a la indentación, la gramática en realidad no es libre de contexto, ya que el hecho de que una sentencia pertenezca al cuerpo de un while depende de la cantidad de tokens de indentación anteriores
  • Como solución, primero se parsea cada línea como una sentencia independiente con su nivel de indentación, y luego se fija el scope observando ese nivel en un simple recorrido lineal
  • Este enfoque es algo hacky, pero funciona y es muy rápido, así que se acepta
  • En el mismo recorrido también se verifica que break y continue solo aparezcan dentro de bucles, return solo dentro de funciones, y las definiciones de campos solo dentro de estructuras

Verificación de tipos e intérprete

  • El primer pase después del parsing resuelve todos los identificadores, conectando directamente los nodos de identificador con los nodos de definición de la variable, función o estructura correspondientes
  • El siguiente pase clave verifica e infiere todos los tipos
  • La inferencia de tipos es en general simple y consiste en comprobaciones condicionales según el tipo de nodo del AST
  • Por ejemplo, el tipo de la expresión dentro de if o while debe ser bool, y los dos operandos de una suma deben ser del mismo tipo numérico, o uno debe ser un entero y el otro un puntero
  • El intérprete inicial es un intérprete de recorrido de árbol que visita directamente los nodos del AST y ejecuta la semántica en C++
  • Las funciones principales son exec() y eval(): exec() ejecuta una sola sentencia y eval() calcula y devuelve el valor de una sola expresión
  • Como C++ tiene tipado estático, eval() devuelve un variant para todos los posibles tipos de valor del lenguaje
  • Las estructuras se representan como un arreglo de pares nombre-valor, uno por cada campo, y se usa el mismo variant para almacenar los valores de las variables
  • El objetivo del intérprete es ejecutar el código del lenguaje de forma multiplataforma y ayudar a depurar la implementación y los programas, no hacerlo rápido
  • El intérprete actual está muy roto, así que hay planes de reescribirlo por completo sobre una base de IR
  • El intérprete existente no puede ejecutar funciones foreign
  • Las funciones foreign deben llamarse con la convención de llamadas de C, y como no se puede saber de antemano la cantidad ni los tipos de los argumentos, probablemente se necesite una técnica vararg o libffi
  • El intérprete puede volcar su estado interno, es decir, los nombres, tipos y valores de las variables, a stdout, y esa fue la forma principal de depurar el parser y el intérprete antes de tener un compilador como se debe

Primer compilador JIT para Aarch64

  • Como durante unas vacaciones a inicios de enero de 2026 solo tenía una Mac M1, la primera arquitectura objetivo del compilador terminó siendo Aarch64 en Mac
  • Por ahora, esa sigue siendo la única arquitectura compatible
  • El compilador es JIT, y el resultado es un bloque de memoria mapeado con el bit ejecutable y punteros al punto de inicio de cada función
  • La estructura de alto nivel es bastante cercana a la de un compilador tradicional basado en pila, pero los resultados de las expresiones se colocan de la misma forma en que una función con ese tipo de retorno dejaría el valor según AAPCS64, la convención estándar de llamadas de C en Aarch64 Mac
  • Los enteros y punteros se devuelven en el registro de propósito general x0, los números de punto flotante en el registro de punto flotante v0, y las estructuras se devuelven en registros o en la pila según su tamaño
  • Este enfoque reduce la cantidad de accesos a memoria, hace que el código generado sea más rápido y además simplifica las llamadas a funciones
  • La pila se usa principalmente para resultados intermedios, como en operaciones binarias
(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
  • Las estructuras de control de flujo se convierten en saltos condicionales, pero en una compilación de una sola pasada todavía no se han compilado los cuerpos de if o while, así que no se conoce el destino del salto
  • Para resolverlo, primero se emite una instrucción de salto con desplazamiento 0, y luego se inserta el desplazamiento real cuando ya se conoce el offset de destino
  • El mismo enfoque se aplica a las llamadas a funciones
  • Para generar instrucciones de la CPU objetivo no se usa ninguna biblioteca de terceros; se implementó directamente para mantener pequeño el compilador
  • La implementación consistió en revisar el manual de instrucciones e ir poniendo los bits necesarios

Partes complicadas en Aarch64

  • Todas las instrucciones de Aarch64 son de 32 bits, así que parece fácil de manejar, pero para meter una constante de 32 bits en un registro se necesitan bits para seleccionar el registro, bits de instrucción y bits de la constante, así que no cabe en una sola instrucción de 32 bits
  • Las constantes de 64 bits son un problema aún mayor
  • Las constantes deben armarse con instrucciones que cargan fragmentos de 16 bits en las posiciones de desplazamiento de 0, 16, 32 y 48 bits, o bien ponerse en memoria de constantes y cargarse desde ahí
  • Para las constantes de punto flotante se usa el enfoque de cargarlas desde memoria de constantes
  • A diferencia de x86, no hay instrucciones push/pop; hay que combinar instrucciones que leen o escriben entre registros y direcciones de memoria y ajustan el registro de dirección
  • Como todas las instrucciones son exactamente de 32 bits, hay que estar pendiente todo el tiempo de si un offset es signed o unsigned, si se multiplica previamente por cierta constante, si modifica el registro de dirección, etc.
  • Al leer y escribir la pila respecto al registro SP, el puntero de pila siempre debe estar alineado a 16 bytes
  • Los offsets posibles están limitados a 12 bits, así que cuando el stack frame supera más o menos los 16 KB se necesita código especial, pero eso todavía no está implementado
  • La convención de llamadas tiene casos especiales donde las estructuras se pasan o devuelven mediante hasta 2 registros de propósito general, registros de punto flotante o un puntero a memoria, y el código del compilador tiene que manejarlo

Introducción de IR y segundo compilador

  • Después de crear el intérprete y compilador básicos, se introdujo una representación intermedia (IR) para reutilizar código, simplificar la escritura de compiladores para otras arquitecturas y hacer optimizaciones
  • La IR empezó pareciéndose a SSA, pero como se pueden reasignar valores al mismo nodo y no se usan nodos phi, en realidad no es SSA
  • La IR es una secuencia de nodos, y cada nodo representa un literal, una operación con nodos de entrada, un salto condicional o incondicional, una llamada a función, etc.
  • Los nodos que representan valores también almacenan el tipo de ese valor
  • Como se permite la reasignación, existe una instrucción IR assign que reasigna el valor de un nodo existente
  • Los saltos condicionales se dividen en jump_if_zero y jump_if_nonzero; esto suele corresponder a instrucciones distintas de CPU y es más rápido que negar el valor y usar la instrucción opuesta
  • Como se admiten punteros a función, hay una instrucción separada para llamar a un nodo IR conocido y otra para llamar a un valor de puntero desconocido
  • Para que en las optimizaciones sea fácil eliminar o insertar nodos en posiciones arbitrarias, los nodos se guardan en std::list y las referencias se hacen con iteradores de lista
  • No se pueden crear literales de valores de estructura, así que existe un nodo alloc para representar un valor de estructura, que normalmente se compila como una reserva de espacio para una estructura no inicializada en la pila
  • Las estructuras se construyen asignando a sus campos individuales
  • Si se representa de forma simple un campo de estructura anidado a.x.y, se leería a.x como un nodo nuevo y luego se leería y de ese nodo, lo que genera mucho desperdicio
  • a.x.y = b también sería ineficiente si se representara como t = a.x, t.y = b, a.x = t, así que la IR da un tratamiento especial a los campos anidados
  • El nodo copy puede extraer cualquier campo anidado arbitrario de una estructura, y el nodo assign puede asignar a cualquier campo anidado arbitrario de una estructura
  • Los campos anidados se representan como un arreglo de índices, como “tomar el campo 0, luego dentro de ese tomar el campo 2, y luego dentro de ese tomar el campo 5”
  • Después, el compilador Aarch64 se reescribió dividiéndolo en un compilador de AST → IR y otro de IR → Aarch64
  • AST → IR es relativamente simple, pero el compilador IR → Aarch64 está actualmente en mucho peor estado que el compilador anterior basado en pila
  • Al inicio de cada función se reserva espacio en la pila para todos los nodos IR que necesita esa función, así que incluso la mayoría de los valores intermedios de vida corta terminan ocupando espacio en el stack frame
  • Una función del raytracer tuvo que dividirse en dos para que su stack frame cupiera dentro del límite de 12 bits mencionado antes
  • Este compilador asume que se usará un asignador de registros, así que se espera que el código generado mejore por varios órdenes de magnitud después

Planes para el compilador e intérprete

  • La implementación actual consta de unas 10,000 líneas de código C++, y le satisface que, para los estándares modernos, el compilador sea pequeño y realmente funcione
  • Asignador de registros

    • El compilador actual de IR → Aarch64 necesita sí o sí un asignador de registros
    • Planea usar un asignador lineal estándar como equilibrio entre velocidad de compilación y calidad del código
  • Optimización de IR

    • Quiere agregar propagación de constantes, simplificación aritmética, eliminación de código muerto, inlining y desenrollado de bucles sobre el IR
    • El objetivo no es superar a GCC o LLVM, pero sí que funciones simples como la suma de vectores 3D se compilen con la menor cantidad posible de instrucciones de CPU
  • Intérprete de IR

    • Planea reescribir el intérprete para que evalúe el IR directamente, y cree que así el intérprete se volverá bastante más simple
  • Generación de ejecutables

    • Actualmente el compilador solo genera blobs de memoria JIT para ejecución inmediata
    • También quiere generar binarios ejecutables en formatos específicos por plataforma, así que tendrá que meterse a fondo con especificaciones de formatos binarios como ELF, Mach-O y PE
    • Uno de los objetivos también es intentar producir ejecutables lo más pequeños posible
  • Depuración

    • Ha seguido bastante el ensamblador generado por el JIT en lldb, y quiere poder depurar correctamente el propio lenguaje
    • Para eso probablemente necesitará soporte para el formato de información de depuración DWARF, sobre el cual por ahora casi no sabe nada

Funciones del lenguaje que quiere agregar

  • Constructores de estructuras

    • Actualmente las estructuras solo permiten establecer todos los campos como en vec3i(1, 2, 3) o inicializarlos en cero como en vec3i()
    • Está considerando que, si se declara una función con el mismo nombre que la estructura, esta actúe como constructor arbitrario
func vec3i(x: i32, y: i32) -> vec3i:
    return vec3i(x, y, 0)
  • Aun así, no está decidido, porque quizá sea mejor darles un nombre propio a esas funciones
  • Variables globales

    • Actualmente no hay soporte para variables globales
    • Planea crear variables globales con la palabra clave global, y como el acceso seguirá limitado por las reglas de alcance, se podrán crear globales locales a función como las variables static de C
    • Las variables de nivel superior no son realmente globales, salvo que se use global, sino variables locales de la función punto de entrada del archivo
    • Esta estructura podría resultar confusa para los usuarios, así que también está considerando otras opciones
    • Como Mac no permite al mismo tiempo mapeos de memoria escribibles y ejecutables, puede que las variables globales tengan que asignarse aparte del código y mapearse con otras banderas
    • El acceso global quizá tenga que hacerse con direcciones resueltas en tiempo de ejecución en vez de offsets conocidos en tiempo de compilación
    • Aun así, como parece posible cambiar las banderas de parte del mapeo con mprotect(), planea probar eso primero
  • Sintaxis de llamada a métodos

    • Por legibilidad, quiere que x.f(y) signifique f(&x, y) o f(&mut x, y) cuando sea posible
  • Polimorfismo

    • Lo considera la función potencial más importante
    • Las opciones más fuertes son sobrecarga de funciones al estilo C++ con plantillas de funciones y estructuras sin restricciones, o traits explícitos al estilo Haskell/Rust con funciones y estructuras genéricas restringidas por traits
    • El estilo C++ es más poderoso, más legible en casos simples y también más fácil de implementar en el compilador, pero puede producir mensajes de error muy crípticos
    • Los traits explícitos pueden ser más legibles en algunos casos y resolver el problema de los mensajes de error, pero requieren un sistema nuevo de traits y trait bounds, lo que hace más difícil la implementación del compilador
    • Todavía no se decide, pero aunque no quería volver a crear C++, se inclina fuertemente por la primera opción
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
  • También quiere inferencia de argumentos de función cuando sea posible
  • Sobrecarga de operadores

    • Necesita polimorfismo de alguna forma
    • a + b podría convertirse en una llamada a una función sobrecargada como add(a, b) o a un método de trait como Add::add
  • Bucles for

    • Como ya se puede imitar con while, planea que for sea un bucle basado en colecciones, como los range-based loops de C++ o los bucles de Python
    • Para eso hace falta una interfaz de range/iterator, y nuevamente se necesita polimorfismo
  • Gestión automática de recursos

    • Considera que un lenguaje práctico y agradable de usar necesita una forma de ayudar a liberar recursos como memoria, archivos, sockets y mutexes
    • Los candidatos son RAII y move al estilo C++, defer al estilo Zig y tipos lineales
    • RAII tiene la desventaja de ser implícito y de agregar instrucciones ocultas y flujo de control oculto
    • defer es explícito, pero hay que escribirlo manualmente cada vez, no evita que se olvide, y es incómodo al liberar colecciones anidadas como un arreglo de archivos
defer free(array)
defer for file in array:
    close(file)
  • Los tipos lineales parecen prometedores porque permiten mantener la explicitud de llamar manualmente a free o close, al mismo tiempo que obligan a consumir los objetos mediante funciones de liberación de recursos
  • Sin embargo, como es difícil combinarlos con colecciones anidadas como arreglos dinámicos de archivos, todavía no se decide
  • Literales polimórficos

    • El arreglo vacío [] permite saber que el tamaño es 0, pero no inferir el tipo de los elementos
    • null puede ser cualquier tipo de puntero, y el literal inf que quiere agregar podría ser cualquier tipo de punto flotante
    • Como solución, considera tres opciones: literales polimórficos al estilo Haskell, tipos especiales integrados o de biblioteca con conversión implícita como nullptr_t en C++, y literales especiales en el AST con manejo ad-hoc del compilador
    • Por ahora se inclina por la última opción: permitir null solo en lugares donde se conoce el tipo de puntero esperado, como al inicializar variables de tipo explícito o al pasar argumentos a funciones
    • Este enfoque es el más simple, pero no es extensible, así que no permite crear tipos personalizados a partir de null
  • Evaluación en tiempo de compilación

    • Quiere declarar variables de tiempo de compilación con la palabra clave const y permitir su uso en expresiones de tiempo de compilación, como tamaños de arreglos
    • Los valores const no se pueden reasignar ni se puede tomar su dirección
    • Las funciones adecuadas podrán llamarse en expresiones de tiempo de compilación cuando no accedan a variables globales ni tengan efectos secundarios
    • El cuerpo de la función actuará como una función normal, pero se ejecutará durante la compilación y su resultado se convertirá en una expresión de tiempo de compilación
    • Hará falta algún mecanismo para marcar funciones foreign que sea seguro llamar en tiempo de compilación, como funciones matemáticas o asignación de memoria
  • Cálculo de tipos

    • Quiere dar soporte a cálculos sobre tipos para metaprogramación
    • Como no quiere crear codificación de tipos en tiempo de ejecución en un lenguaje estático, y además la utilidad de los tipos en runtime es limitada, planea que esto exista solo en tiempo de compilación
    • También cree que funcionalidades parecidas a los concepts de C++ podrían implementarse como llamadas en tiempo de compilación sin una sintaxis aparte
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
  • Corrutinas

    • Agregar async/await al estilo Python o JS es más un deseo que un plan

Planes para bibliotecas y módulos

  • Módulos

    • Es inviable escribir todo el código en un solo archivo, así que se necesitan módulos
    • Se planea una instrucción simple como import lib.sublib, que pueda colocarse en cualquier parte del código y que también siga las reglas de alcance
    • El alcance solo afecta la visibilidad; la carga real ocurre en tiempo de compilación, y el punto de entrada del módulo importado se ejecuta antes que el módulo actual
    • El nombre de la biblioteca corresponde directamente a una ruta del sistema de archivos basada en la ruta raíz indicada al compilador o intérprete
    • Si es un solo archivo fuente, se importa solo ese archivo; si es un directorio, se importan todos los archivos de ese directorio en algún orden
    • Hace falta una sintaxis para apuntar a archivos del mismo directorio, y se está considerando algo como import .another
    • Las funciones y variables globales importadas pueden usarse sin prefijo y, cuando haya ambigüedad, se puede agregar el prefijo del nombre de la biblioteca, como en io.print(x)
    • Está previsto que el punto de entrada de los módulos se ejecute en un orden determinista según el orden de importación y el orden topológico de las importaciones recursivas, lo que podría resolver el problema del orden de inicialización de C o C++
    • La disposición de memoria de programas con varios módulos todavía no está definida
    • Se podría usar un parche de memoria separado para cada módulo y resolver en tiempo de ejecución las llamadas a funciones y el acceso a variables globales, o bien crear un único mapeo grande de memoria y usar offsets relativos
    • Un solo mapeo grande puede ser más rápido en tiempo de ejecución, pero dificulta la compilación paralela de varios módulos
  • Prelude

    • Cuando existan módulos, las utilidades básicas podrían ponerse en un módulo prelude incluido implícitamente en todos los programas
    • Entre los candidatos están una función length() para arreglos integrados, una interfaz de iterador, un tipo string_view y rangos numéricos como range(n) de Python
  • Literales de cadena

    • Todavía no hay literales de cadena, y aún no se ha decidido qué significado deberían tener
    • El plan es tener un tipo inmutable string_view en el prelude, colocar el contenido de las cadenas en algún lugar de la memoria ejecutable y convertir el literal en sí en un string_view que apunte a esa memoria
  • Biblioteca estándar

    • Cuando existan módulos, también hará falta una biblioteca estándar
    • El alcance que se quiere incluir abarca una biblioteca matemática con vectores y matrices, gestión de memoria tipo alloc/free enlazada desde libc, arreglos dinámicos, cadenas dinámicas y formateo, tablas hash, IO de consola y archivos, utilidades para el sistema de archivos, utilidades de tiempo y reloj, y redes

Prioridades actuales

  • Aún no se ha definido cuándo se implementarán las funciones planeadas ni si este lenguaje se usará de verdad para modding de juegos u otros fines
  • Se considera que no es buena idea avanzar en serio con varios proyectos ambiciosos al mismo tiempo, así que la prioridad actual sigue siendo el desarrollo de juegos
  • Como no se puede hacer modding de un juego antes de que el juego exista, el trabajo en el lenguaje avanza solo cuando dan ganas de hacerlo

1 comentarios

 
GN⁺ 4 시간 전
Opiniones en Lobste.rs
  • Los comentarios de aquí se sienten mucho más duros de lo que esperaba de esta comunidad
    Es posible que otro lenguaje como Lua hubiera sido más que suficiente. También es posible que el autor se haya metido en un enorme yak shaving
    Aun así, está claro que tiene mucha habilidad y que lo está disfrutando bastante, y también hay contenido técnico interesante en el texto
    Si es el artículo de otro nerd colega diseñando otro lenguaje de scripting para un motor de juegos, con gusto lo leo y lo disfruto. Si eso me permite evitar aunque sea un texto basura generado por IA sobre cómo un SaaS hecho con vibecoding va a salvar al mundo y hacer rico al autor, podría leer mil textos como este al día

  • La afirmación de que “Lua u otros lenguajes de scripting compilados con JIT son la opción estándar, pero hacer sandboxing es realmente difícil” de verdad cuesta entenderla
    Que hacer sandboxing en Lua sea fácil es una de sus mayores ventajas, y no solo para mods o plugins. Ningún otro lenguaje que haya visto se le acerca en esto

    • Todo ese párrafo se lee como “he leído un poco sobre este lenguaje, pero no pienso investigar unas cuantas horas sobre algo que ha sido la opción estándar durante los últimos 20 años”
      El tema de las versiones de Lua tiene algo de sentido, pero en la práctica no he visto que la gente se enfurezca demasiado por eso. Salvo cuando alguien usa Lua “moderno” para algo y luego tiene que bajar a 5.1/5.2 por otra tarea, parece que la mayoría usa solo uno de los dos
    • También es bastante raro que la “posibilidad común” sea únicamente Lua y C++. ¿Como si solo existieran dos clases de lenguajes?
      Da mucho la impresión de que investigó para justificar “quiero hacer mi propio lenguaje”. Eso en sí está bien, pero es mejor ser honesto que hacer afirmaciones completamente equivocadas sobre las opciones existentes
    • Otra cosa que me choca del texto es que, si quieres aprender diseño de lenguajes, suele ser mucho mejor escribir un compilador de un lenguaje host que apunte a una máquina virtual o runtime ya existente, en vez de bajar hasta lo más básico
      Claro, si te interesa el diseño de máquinas virtuales o las partes más de bajo nivel, el enfoque del artículo también sirve. Pero está lejos de ser la mejor forma de aprender diseño de lenguajes
    • Incluso juegos hechos por programadores muy capaces han sufrido escapes del sandbox de Lua. Factorio, Binding of Isaac y, si consideras la programación en la nube como un juego raro donde todos pierden, también ~~Redis~~; así que sospecho que hay algún problema en cómo se presenta la API
      El ejemplo más sencillo es el escape por bytecode. Si sabes que existe, puedes desactivarlo, pero el hecho de que esto siga pasando revela un problema más amplio. Para armar reglas de sandboxing tienes que entender cómo interactúan partes separadas de la especificación de Lua; no es una estructura donde puedas componer programas seguros a partir de piezas básicas claramente definidas que indiquen qué interacciones adicionales permiten
      Un ejemplo más rebuscado es la contaminación de prototipos entre entornos distintos dentro de la misma VM de Lua. En Redis se podía contaminar la metatable de string, y eso permitía ejecutar código con los privilegios de otros usuarios de la base de datos que usaran funciones de Lua. Lua tiene una superficie de contaminación de prototipos astronómicamente menor que algo como JavaScript, pero da risa que, aun teniendo más o menos solo 2 prototipos globales, puedas hacer esencialmente lo mismo con uno de ellos
      Aun así, Luau tiene una solución bastante competente para este problema, y no me queda claro por qué el autor asume que, si crea un sandbox nuevo, va a evitar implícitamente todos esos mismos problemas
  • La parte de “mi juego está muy orientado a la simulación. Simula cientos de miles de entidades con un motor ECS personalizado. Idealmente, me gustaría que el lenguaje de modding pudiera recibir varios punteros a componentes y recorrerlos como un for loop en C” podría aspirar a algo mejor
    En particular, valdría la pena comparar cómo manejan este problema motores de renderizado como Unity, Unreal, Blender o Godot. La iteración externa no es lo bastante rápida para hablar de megapíxeles por segundo, y puede que tampoco alcance para decenas de miles de entidades por segundo. Aquí hay que pensar en paralelismo
    Los motores grandes son todos amigables con la GPU y normalmente usan descripciones de flujo de datos para algoritmos sin ramas vergonzosamente paralelizables. Puede que al autor no le gusten los editores visuales, y esa idea es común, pero eso no significa que la respuesta sea un for loop
    Si el autor hubiera mencionado que ECS es esencialmente un paradigma relacional y que el lenguaje histórico cargado de equipaje con el que habría que compararlo es SQL, quizá lo habría mirado con más indulgencia