- pslang começou a partir do interesse na capacidade de modding de jogos grandes e no assembly gerado por compiladores C++, e hoje já funciona a ponto de permitir escrever um path tracer de Monte Carlo com cerca de 1.000 LOC
- Uma linguagem de modding precisa de interoperabilidade com C, manipulação de arrays e ponteiros de baixo nível, sandboxing fácil, compilador pequeno e compilação rápida; Lua e mods nativos em C++ têm limitações respectivamente em desempenho/integração, sandboxing e distribuição
- pslang é uma linguagem de baixo nível, imperativa, de avaliação imediata e baseada em chamada por valor, com sistema de tipos estático, estrito e nominal, escopo baseado em indentação, arrays embutidos, tipos de função, ponteiros e layout de memória garantido
- O compilador é dividido em parser baseado em Bison, verificação de tipos na AST, IR, interpretador e JIT; no momento, o único alvo suportado é Aarch64 Mac, e após a introdução de IR a qualidade do código gerado ainda é baixa por falta de um alocador de registradores
- A implementação atual tem cerca de 10.000 linhas de código C++, e no futuro podem ser avaliados recursos como alocador de registradores, otimizações de IR, interpretador de IR, geração de executáveis, informações de depuração, polimorfismo, módulos e biblioteca padrão
Contexto que levou à criação do pslang
- Depois de cerca de 17 anos programando, cresceu a vontade de criar pessoalmente uma linguagem pensada não como brinquedo, mas com algum uso prático em mente
- No passado, foram feitos interpretadores de linguagens esotéricas como FALSE e vários interpretadores de cálculo lambda, mas isso não satisfazia o desejo de criar uma linguagem “de verdade”
- Como o jogo de grande escala em desenvolvimento tem uma estrutura adequada para modding, ao pensar em como permitir mods surgiu a ideia de que uma linguagem de programação customizada poderia ser uma solução simples
- Em dezembro de 2025, ao acompanhar o Advent of Compiler Optimisations de Matt Godbolt, veio o interesse em seguir o assembly gerado por compiladores C++, e isso reacendeu a vontade de mexer com assembly
- A linguagem atual ainda está longe de ter qualidade de produção, mas já foi implementada a ponto de permitir escrever um path tracer de Monte Carlo funcional com cerca de 1.000 LOC
Requisitos de modding e limites das opções existentes
- Como o jogo simula centenas de milhares de entidades com um engine ECS customizado, deseja-se que a linguagem de modding consiga receber conjuntos de ponteiros para componentes e iterar sobre eles como em um loop
for em C
- Como mods são difíceis de controlar, o ideal é que o sandboxing seja fácil para proteger os jogadores, preferencialmente com um único switch capaz de desativar todo IO e recursos semelhantes
- Modding precisa ser simples a ponto de bastar colocar scripts em uma pasta específica para usá-los imediatamente como mods
-
Lua e linguagens de script com JIT
- Lua é a escolha padrão, mas parece exigir sandboxing por meio de um pré-processamento que remove funções de IO da biblioteca padrão antes de executar código não confiável, o que não passa a sensação de uma solução robusta
- Como Lua é uma linguagem dinâmica e de alto nível, ela não entende ponteiros C diretamente; assim, para integrar a iteração sobre entidades do ECS, seria necessário alternar native ↔ Lua ↔ native a cada entidade, ou transformar entidades nativas em arrays Lua para depois desmontá-los novamente
- Lua padrão e LuaJIT vêm divergindo há várias versões, o que pode causar confusão tanto para modders quanto para implementadores
-
C++ e mods nativos
- Se os mods forem feitos em C++, o problema da iteração sobre entidades desaparece, mas a distribuição de binários passa a exigir ambiente de desenvolvimento e repositório de artefatos binários para todas as plataformas
- Para distribuir como código-fonte, seria necessário incluir um compilador C++ no jogo, e uma instalação padrão do LLVM já ocupa 10 a 20 vezes mais espaço em disco do que o tamanho atual do jogo
- Se uma DLL nativa declarar e usar
int open();, torna-se praticamente impossível bloquear acesso ao sistema de arquivos ou à rede, inviabilizando o sandboxing
- O mesmo problema se aplica a outras linguagens nativas, como Rust
- Modding é um dos objetivos, mas ainda não está claro se essa linguagem será realmente usada para modding de jogos, e não se quer especializá-la demais para um único caso de uso
Objetivos de projeto da linguagem
- Busca-se oferecer interoperabilidade com C sem fricção, de forma que a conexão entre o código nativo do jogo e o código de modding seja tão simples quanto uma chamada de função
- Como é preciso lidar com arrays brutos de entidades, são necessários recursos de baixo nível
- A linguagem deve ser prática e agradável de usar, para que modders possam escrever código com um nível razoável de conveniência
- O sandboxing deve ser fácil, e o compilador também precisa ser pequeno
- Não se quer colocar um compilador de 1 GB dentro de um jogo de 50 MB, então a ideia é reduzir o footprint do compilador
- Também é necessária compilação rápida, para que os jogadores não precisem esperar muito pela compilação dos mods, embora parte disso possa ser mitigada com cache extensivo
- Deseja-se cross-platform de verdade, mas aceitam-se algumas premissas, como poucos desktops amplamente usados, 64 bits e suporte a IEEE754
- Basta ter desempenho razoavelmente bom em comparação com a maioria das linguagens dinâmicas
- Como C++ foi a linguagem principal por muito tempo, ela influenciou fortemente a visão de linguagem, mas a intenção é evitar recriar simplesmente o C++
Modelo atual da linguagem pslang
- O nome de trabalho é pslang, derivado do engine de jogo psemek; trata-se de uma linguagem imperativa, de avaliação imediata, chamada por valor e baixo nível
- O sistema de tipos é estático, estrito e nominal
- O exemplo básico a seguir usa funções, structs, tipos de função e retorno de array ao mesmo tempo
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]
Escopo e tipos básicos
- Usa escopo baseado em indentação para que a linguagem lembre uma linguagem de script e pareça mais amigável para iniciantes
- Atualmente a indentação usa tabulações, mas no futuro isso pode mudar para espaços
- Corpos de função, corpos de loop, corpos de
if etc. criam novos escopos; funções e structs podem ser definidos dentro de qualquer escopo e só ficam visíveis dentro dele
- Funções locais não podem acessar variáveis do escopo em que foram definidas, então não são closures; o escopo afeta apenas a resolução de nomes
- O escopo de topo é tratado como qualquer outro e inclui um ponto de entrada executado quando o arquivo é carregado ou inicializado
- Há 13 tipos básicos no total:
bool, 4 inteiros com sinal, 4 inteiros sem sinal, 3 tipos de ponto flutuante e unit
i8 i16 i32 i64
u8 u16 u32 u64
f16 f32 f64
f8 não foi incluído porque não é suportado pela maioria das CPUs desktop e não há consenso sobre a semântica de ponto flutuante de 8 bits
f16 é menos útil para o usuário comum, mas é frequente em gráficos, como em cores HDR e atributos de vértice, e a maioria das CPUs desktop modernas implementa IEEE754 f16, por isso há suporte nativo
- Toda aritmética inteira segue complemento de dois com overflow, sem comportamento indefinido
unit tem apenas um único valor, unit(), e é o tipo de retorno formal para funções sem valor de retorno
- Funções cujo tipo de retorno é omitido retornam
unit automaticamente, e se o return ao final de uma dessas funções for omitido, ele é inserido automaticamente
- É erro uma função que não seja do tipo
unit não retornar um valor
Literais, arrays, tipos de função e ponteiros
- O número
10 é i32 por padrão, e o tamanho é especificado com sufixos como 10b, 10s e 10l
- Literais sem sinal usam o sufixo
u, como em 10ub, 10us, 10u e 10ul
- Literais de ponto flutuante com casa decimal são
f32 por padrão; 10.0h é de 16 bits e 10.0d é de 64 bits
- Não é possível omitir a parte inteira ou a parte fracionária como em
10. ou .5; é preciso escrever de forma completa, como 10.0 e 0.5
- Todos os literais numéricos têm um tipo não ambíguo
- Arrays são tipos embutidos de primeira classe e, ao contrário de C/C++, é possível passar o array inteiro para funções, retorná-lo ou atribuí-lo a outro array
- O tamanho do array é sempre conhecido em tempo de compilação e ele se comporta como uma struct com vários campos do mesmo tipo
- O tipo de array é escrito como
i32[5], e um literal de array como [1, 2, 3, 4, 5]
- Tipos de função são mais próximos de ponteiros de função em C, escritos no formato
(a, b, c) -> d; se houver apenas um argumento, os parênteses podem ser omitidos, como em a -> b
- Internamente, um tipo de função é um ponteiro de função comum sem dados associados, e não uma closure
- Tipos de ponteiro são escritos como
i32*; por padrão, são ponteiros imutáveis, e ponteiros mutáveis são declarados como i32 mut*
- O endereço de uma variável é
&x, um ponteiro mutável é &mut x, a desreferenciação é *p, e a aritmética de ponteiros é usada como em *(p + 10)
Structs, layout de memória e tipos vazios
- Structs são declaradas com a palavra-chave
struct e uma lista de campos
struct string_view:
size: u64
data: u8*
- Structs são criadas com um construtor funcional embutido, como
string_view(10, data), e os campos são acessados com ponto, como em v.x
- Também é possível acessar campos em ponteiros para struct com a mesma sintaxe de ponto
- Não há especificador separado de mutabilidade nos campos da struct; campos de objetos mutáveis são mutáveis, e campos de objetos imutáveis são imutáveis
- Não existem modificadores de acesso, e os campos são sempre public
- Todos os objetos têm layout de memória garantido; tipos básicos têm alinhamento igual ao seu tamanho, e
bool ocupa 1 byte
- Ponteiros e tipos de função são sempre de 64 bits e têm o mesmo alinhamento
- Arrays têm o mesmo alinhamento de seus elementos, e structs recebem padding para satisfazer os requisitos de alinhamento
- Essa garantia existe principalmente para simplificar interoperabilidade com C e usos em programação para GPU
unit e structs sem campos são tratados como tipos vazios, com apenas um único valor válido, e seu tamanho real é 0 byte
- Passar tipos vazios para funções, declará-los como variáveis ou usá-los como campos não afeta o uso de memória nem o tamanho da struct
- Tipos vazios podem ser usados como tags de tempo de compilação em nível de tipo
- Leituras/escritas por meio de ponteiros para tipos vazios ainda não foram definidas, e atualmente aritmética de ponteiros com esses tipos é ilegal
- A linguagem não segue a regra do C++ de que cada objeto deve ter um endereço de memória único
Variáveis, funções, fluxo de controle e funções externas
- Variáveis imutáveis são declaradas como
let x = 10, e variáveis mutáveis como mut x = 20
- Não é possível criar um ponteiro mutável para uma variável imutável
- É possível explicitar o tipo, como em
let x: i32 = 10, mas isso não é obrigatório, pois a linguagem foi projetada para inferir sem ambiguidades os tipos de todas as expressões
- Todas as variáveis devem ser inicializadas
- Funções são escritas como
func foo(x: A, y: B) -> C: seguidas do corpo; se o tipo de retorno for omitido, ele será unit
- Todas as funções seguem a ABI C nativa da plataforma em execução, uma decisão tomada para interoperabilidade com C, callbacks e passagem como ponteiros de função para sistemas ECS
- Dentro do mesmo escopo, a ordem de declaração de funções e structs é livre, então é possível usar antes uma função ou struct declarada mais adiante
- Como todos os argumentos de função e tipos de retorno devem ser totalmente explícitos, essa liberdade na ordem de declaração não complica a inferência de tipos
- Existem instruções
if/else if/else e laços while; ainda não há laço for
- A forma de expressão de
if é usada como if A then B else C
- Funções externas são declaradas como
foreign func sin(x: f64) -> f64, e sua implementação deve ser ligada em outro lugar
- Atualmente, o interpretador procura essas funções no próprio executável do interpretador via
dlsym
- Funções externas são o principal mecanismo de interoperabilidade com bibliotecas C e bibliotecas de terceiros; o exemplo do raytracer usa esse recurso para calcular raiz quadrada, gravar arquivos, medir tempo e criar threads
Conversão de tipos e operadores
- Não há nenhum casting implícito; conversões manuais usam o operador
as, como em (x as f32)
- Todos os tipos numéricos podem ser convertidos entre si, e todos os tipos de ponteiro também, exceto converter um ponteiro imutável em ponteiro mutável
- Tipos de ponteiro podem ser convertidos para
u64, e u64 pode ser convertido para tipos de ponteiro
bool não pode ser convertido para nenhum outro tipo nem a partir de outro tipo
- Está sendo considerado adicionar uma conversão implícita de
T mut* para T*
- Operadores padrão como aritméticos, lógicos e de comparação são oferecidos em geral
&, |, && e || funcionam tanto com booleanos quanto com inteiros; & e | sempre avaliam ambos os operandos, enquanto && e || fazem short-circuit
- Operações aritméticas e comparações só funcionam em pares do mesmo tipo numérico; não há promoção de tipos numéricos
- Os recursos atuais da linguagem podem não parecer muitos, mas já permitem escrever programas reais com relativo conforto
Estrutura do compilador
- O projeto é dividido em várias bibliotecas
types: definição do sistema de tipos
ast: definição da árvore sintática abstrata e utilitários
parser: parser
ir: representação intermediária
interpreter: interpretador
jit: compilador JIT
- A ideia é manter o interpretador e o compilador como aplicativos CLI simples que usam essas bibliotecas; atualmente, existe apenas um interpretador em modo JIT
- Para embutir a linguagem, basta usar as bibliotecas
parser e jit
Parser e tratamento de indentação
- Usa Bison como gerador de parser
- Os tokens são definidos na lexer grammar, e a gramática da linguagem na parser grammar
- Um arquivo é uma lista de instruções, e uma instrução pode ser uma declaração de função, operador de fluxo de controle, declaração de variável, expressão etc.; expressões podem ser literais, variáveis, operadores, chamadas de função etc.
- Foi necessário corrigir alguns conflitos shift/reduce na gramática, e a flag
-Wcounterexamples do Bison foi usada para verificar as situações exatas que causavam os conflitos
- O skeleton
lalr1.cc do Bison é usado para gerar uma classe de parser em C++
- O Bison padrão gera um parser em C com o estado do parser em variáveis globais, o que não serve para casos como interpretadores ou modos de jogo, em que vários arquivos precisam poder ser parseados em paralelo
- A execução do Bison é incluída na etapa de build dos scripts do CMake
- A saída do parser é um objeto C++ que representa a AST do arquivo parseado
- Por causa da indentação, a gramática não é de fato livre de contexto, pois o fato de uma instrução pertencer ao corpo de um
while depende da quantidade de tokens de indentação anteriores
- Como solução, cada linha é primeiro parseada como uma instrução independente com seu nível de indentação, e depois um simples passe linear usa esse nível para determinar os escopos
- Essa abordagem é meio gambiarra, mas funciona e é muito rápida, então foi aceita
- No mesmo passe, também se verifica que
break e continue apareçam apenas dentro de loops, return apenas dentro de funções e definições de campo apenas dentro de structs
Verificação de tipos e interpretador
- Após o parsing, o primeiro passo resolve todos os identificadores, conectando os nós de identificador diretamente aos nós de definição de variável, função e struct correspondentes
- O próximo passo central verifica e infere todos os tipos
- A inferência de tipos é em geral simples e consiste em checagens condicionais com base no tipo específico de nó da AST
- Por exemplo, o tipo da expressão dentro de
if ou while deve ser bool, e os dois operandos de uma soma devem ter o mesmo tipo numérico, ou então um lado deve ser inteiro e o outro um ponteiro
- O interpretador inicial é um interpretador tree-walking que visita diretamente os nós da AST e executa a semântica em C++
- As funções principais são
exec() e eval(): exec() executa uma única instrução, e eval() calcula e retorna o valor de uma única expressão
- Como C++ é estaticamente tipado,
eval() retorna um variant para todos os tipos de valor possíveis da linguagem
- Structs são representadas como um array de pares nome-valor, um para cada campo, e o mesmo
variant também é usado para armazenar valores de variáveis
- O objetivo do interpretador é executar o código da linguagem de forma multiplataforma e ajudar na depuração da implementação e dos programas; ele não foi feito para ser rápido
- O interpretador atual está muito quebrado, então há planos de reescrevê-lo completamente com base em IR
- O interpretador antigo não consegue executar funções
foreign
- Funções
foreign precisam ser chamadas usando a convenção de chamada de C, e como não é possível saber antecipadamente a quantidade e os tipos dos argumentos, pode ser necessário usar técnicas de vararg ou libffi
- O interpretador pode despejar seu estado interno — ou seja, nomes, tipos e valores das variáveis — em stdout, e esse foi o principal meio de depurar o parser e o interpretador antes de existir um compilador de verdade
Primeiro compilador JIT para Aarch64
- No começo de janeiro de 2026, durante as férias, eu só tinha um M1 Mac, então a primeira arquitetura-alvo do compilador acabou sendo Aarch64 no Mac
- Até hoje, essa continua sendo a única arquitetura suportada
- O compilador é do tipo JIT, e o resultado é um blob de memória mapeado com bit executável e ponteiros para o ponto inicial de cada função
- A estrutura de alto nível é bem próxima de um compilador tradicional baseado em pilha, mas os resultados das expressões são colocados da mesma forma que uma função com o mesmo tipo de retorno colocaria o valor segundo a AAPCS64, a convenção de chamada padrão de C no Aarch64 Mac
- Inteiros e ponteiros são retornados no registrador de propósito geral
x0, valores de ponto flutuante no registrador de ponto flutuante v0, e structs são retornadas em registradores ou na pilha, dependendo do tamanho
- Essa abordagem reduz o número de acessos à memória, tornando o código gerado mais rápido, e também simplifica chamadas de função
- A pilha é usada principalmente para resultados intermediários, como em operações binárias
(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
- Estruturas de controle de fluxo viram saltos condicionais, mas em compilação de passagem única ainda não se conhece o destino do salto, porque o corpo de
if ou while ainda não foi compilado
- Para resolver isso, primeiro é emitida uma instrução de salto com deslocamento 0 e, depois que o deslocamento de destino é conhecido, o deslocamento real do salto é injetado
- O mesmo método é aplicado a chamadas de função
- Para gerar instruções da CPU-alvo, nenhuma biblioteca de terceiros foi usada; para manter o compilador pequeno, isso foi implementado manualmente
- A implementação foi feita consultando o manual de instruções e preenchendo os bits necessários
Partes complicadas no Aarch64
- Todas as instruções do Aarch64 têm 32 bits, o que parece fácil de lidar, mas para colocar uma constante de 32 bits em um registrador são necessários bits de seleção de registrador, bits da instrução e bits da constante, e tudo isso não cabe em uma única instrução de 32 bits
- Constantes de 64 bits são um problema ainda maior
- As constantes precisam ser montadas com instruções que carregam pedaços de 16 bits nos offsets 0, 16, 32 e 48 bits, ou então colocadas em memória constante e carregadas de lá
- Constantes de ponto flutuante são carregadas da memória constante
- Ao contrário do x86, não existem instruções push/pop; é preciso combinar instruções que leem/escrevem entre registradores e endereços de memória e ajustam o registrador de endereço
- Como todas as instruções têm exatamente 32 bits, é preciso ficar atento o tempo todo a detalhes como se o deslocamento é signed ou unsigned, se ele é pré-multiplicado por alguma constante específica e se o registrador de endereço é modificado
- Ao ler e escrever a pilha com base no registrador SP, o ponteiro da pilha deve estar sempre alinhado em 16 bytes
- Os offsets possíveis ficam limitados a 12 bits, então quando o stack frame passa de cerca de 16 KB é necessário um código especial, mas isso ainda não foi implementado
- A convenção de chamada tem casos especiais em que structs são passadas ou retornadas por até 2 registradores de propósito geral, registradores de ponto flutuante ou ponteiros de memória, e o código do compilador precisa lidar com isso
Introdução da IR e o segundo compilador
- Depois de criar o interpretador básico e o compilador, foi introduzida uma representação intermediária (IR) para reutilização de código, simplificar a escrita de compiladores para outras arquiteturas e permitir otimizações
- A IR começou parecida com SSA, mas como é possível reatribuir valores ao mesmo nó e não são usados nós phi, ela na prática não é SSA
- A IR é uma sequência de nodes, e cada nó representa um literal, uma operação com nós de entrada, um salto condicional ou incondicional, uma chamada de função etc.
- Nós que representam valores também armazenam o tipo desse valor
- Como reatribuições são permitidas, existe uma instrução IR
assign que reatribui o valor de um nó existente
- Saltos condicionais são separados em
jump_if_zero e jump_if_nonzero; isso normalmente corresponde a instruções diferentes de CPU e é mais rápido do que negar o valor e usar a instrução oposta
- Como há suporte a ponteiros de função, existe uma instrução separada para chamar um nó IR conhecido e outra para chamar um valor de ponteiro desconhecido
- Para facilitar otimizações que removem ou inserem nós em posições arbitrárias, os nós são armazenados em
std::list e as referências são iteradores da lista
- Não dá para criar literais de valor do tipo struct, então há um nó
alloc que representa um valor struct, normalmente compilado como alocação de espaço não inicializado para a struct na pilha
- Structs são construídas por atribuições a campos individuais
- Se campos aninhados de struct como
a.x.y forem representados de forma simples, seria preciso ler a.x para um novo nó e depois ler y desse nó, o que desperdiça bastante
a.x.y = b também fica ineficiente se for representado como t = a.x, t.y = b, a.x = t, então a IR trata campos aninhados de forma especial
- O nó
copy pode extrair qualquer campo aninhado arbitrário de uma struct, e o nó assign pode atribuir a qualquer campo aninhado arbitrário de uma struct
- Campos aninhados são representados como um array de índices, tipo “pegar o campo 0, depois o campo 2 dentro dele, depois o campo 5 dentro desse”
- Depois disso, o compilador Aarch64 foi reescrito separando-o em um compilador AST → IR e um compilador IR → Aarch64
- AST → IR é relativamente simples, mas o compilador IR → Aarch64 atualmente está em um estado muito pior do que o compilador antigo baseado em pilha
- No início de cada função, ele aloca espaço de pilha suficiente para todos os nós IR necessários àquela função, então até valores intermediários de vida curtíssima acabam ocupando espaço no stack frame
- Uma função do raytracer precisou ser dividida em duas para que o stack frame coubesse dentro do limite de 12 bits mencionado antes
- Esse compilador foi feito assumindo o uso de um alocador de registradores, então a expectativa é que o código gerado melhore várias ordens de grandeza depois disso
Planos para compilador e interpretador
- A implementação atual consiste em cerca de 10.000 linhas de código C++, e ele está satisfeito por, para os padrões modernos, o compilador ser pequeno e realmente funcionar
-
Alocador de registradores
- O compilador atual de IR → Aarch64 precisa muito de um alocador de registradores
- Ele pretende usar um alocador linear scan padrão como compromisso entre velocidade de compilação e qualidade do código
-
Otimização de IR
- Quer adicionar, com base no IR, propagação de constantes, simplificação aritmética, eliminação de código morto, inlining e unrolling de loops
- O objetivo não é superar GCC ou LLVM, mas quer que funções simples, como soma de vetores 3D, sejam compiladas para o menor número possível de instruções de CPU
-
Interpretador de IR
- Pretende reescrever o interpretador no modelo de avaliação direta de IR, e acredita que isso tornará o interpretador consideravelmente mais simples
-
Geração de executáveis
- Atualmente, o compilador gera apenas um blob de memória JIT para execução imediata
- Também quer produzir binários executáveis em formatos específicos de cada plataforma, o que exigirá estudar especificações de formatos binários como ELF, Mach-O e PE
- Um dos objetivos também é tentar gerar executáveis o menor possível
-
Depuração
- Já acompanhou bastante o assembly gerado pelo JIT no lldb, e quer conseguir depurar adequadamente a própria linguagem
- Para isso, provavelmente será necessário suporte ao formato de informação de depuração DWARF, sobre o qual ele atualmente quase nada sabe
Recursos da linguagem que ele quer adicionar
-
Construtores de struct
- Atualmente, structs só podem ser usadas definindo todos os campos, como em
vec3i(1, 2, 3), ou inicializadas com zero, como em vec3i()
- Ele considera permitir que uma função declarada com o mesmo nome da struct funcione como construtor arbitrário
func vec3i(x: i32, y: i32) -> vec3i:
return vec3i(x, y, 0)
- No entanto, ele ainda não decidiu, porque talvez seja melhor dar um nome distinto a esse tipo de função
-
Variáveis globais
- Atualmente, variáveis globais não são suportadas
- Ele planeja criar variáveis globais com a palavra-chave
global, e o acesso ainda ficará sujeito às regras de escopo, então seria possível criar globais locais de função, como variáveis static em C
- Variáveis de nível superior não são globais de verdade, a menos que usem
global; caso contrário, são variáveis locais da função de ponto de entrada do arquivo
- Essa estrutura pode ser confusa para usuários, então ele está considerando outras opções também
- Como o macOS não permite ao mesmo tempo mapeamentos de memória graváveis e executáveis, variáveis globais talvez precisem ser alocadas separadamente do código e mapeadas com flags diferentes
- O acesso a globais talvez tenha de usar endereços resolvidos em tempo de execução, em vez de offsets conhecidos em tempo de compilação
- No entanto, como parece ser possível mudar as flags de parte de um mapeamento com
mprotect(), ele pretende tentar isso primeiro
-
Sintaxe de chamada de método
- Por legibilidade, ele quer que
x.f(y) signifique f(&x, y) ou f(&mut x, y) quando isso for possível
-
Polimorfismo
- Ele vê isso como o recurso potencial mais importante
- As opções mais prováveis são sobrecarga de função no estilo C++ com templates irrestritos de função e de struct, ou traits explícitas no estilo Haskell/Rust com funções e structs genéricas restritas por trait
- O estilo C++ é mais poderoso, mais legível em casos simples e mais fácil de implementar no compilador, mas pode gerar mensagens de erro extremamente difíceis de entender
- Traits explícitas podem ser mais legíveis em alguns casos e resolvem o problema das mensagens de erro, mas exigem um novo sistema de trait e trait bound, tornando a implementação do compilador mais difícil
- Ele ainda não decidiu, mas, apesar de não querer recriar C++, está fortemente inclinado à primeira opção
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
- Ele também quer inferência de argumentos de função quando possível
-
Sobrecarga de operadores
- Isso exige polimorfismo de alguma forma
a + b poderia se tornar uma chamada a uma função sobrecarregada como add(a, b) ou a um método de trait como Add::add
-
Loops for
- Como já é possível imitar isso com
while, ele pretende usar for como loop baseado em coleções, no estilo range-based loop de C++ ou loops do Python
- Para isso, será necessária uma interface de range/iterador, o que novamente exige polimorfismo
-
Gerenciamento automático de recursos
- Ele considera que uma linguagem prática e agradável de usar precisa de alguma forma de ajudar a liberar recursos como memória, arquivos, sockets e mutexes
- Os candidatos são RAII com move no estilo C++,
defer no estilo Zig e tipos lineares
- RAII tem a desvantagem de ser implícito, adicionando comandos e fluxo de controle ocultos
defer é explícito, mas precisa ser escrito manualmente toda vez, não impede esquecimentos e é incômodo ao liberar coleções aninhadas, como um array de arquivos
defer free(array)
defer for file in array:
close(file)
- Tipos lineares parecem promissores porque mantêm a explicitude de chamar manualmente
free ou close, ao mesmo tempo que forçam que o objeto seja consumido pela função de liberação de recurso
- Mas eles são difíceis de combinar com coleções aninhadas, como arrays dinâmicos de arquivos, então ele ainda não decidiu
-
Literais polimórficos
- O array vazio
[] permite saber que o tamanho é 0, mas não permite inferir o tipo dos elementos
null pode ser qualquer tipo de ponteiro, e o literal inf, que ele quer adicionar, pode ser qualquer tipo de ponto flutuante
- Como solução, ele considera três opções: literais polimórficos ao estilo Haskell, tipos especiais embutidos ou de biblioteca com conversão implícita como
nullptr_t do C++, e literais especiais na AST com tratamento ad hoc pelo compilador
- No momento, ele está inclinado à última abordagem, permitindo
null apenas em posições em que o tipo de ponteiro esperado seja conhecido, como inicialização de variável com tipo explícito ou passagem de argumento para função
- Essa abordagem é a mais simples, mas não é extensível, então não permite criar tipos personalizados a partir de
null
-
Avaliação em tempo de compilação
- Ele quer declarar variáveis de tempo de compilação com a palavra-chave
const e permitir seu uso em expressões de tempo de compilação, como tamanho de arrays
- Valores
const não podem ser reatribuídos nem ter seu endereço obtido
- Funções apropriadas poderão ser chamadas em expressões de tempo de compilação quando não acessarem variáveis globais nem tiverem efeitos colaterais
- O corpo da função funcionará como o de uma função comum, mas será executado durante a compilação e o resultado se tornará uma expressão de tempo de compilação
- Será necessário algum mecanismo para marcar funções
foreign que sejam seguras para chamar em tempo de compilação, como funções matemáticas ou alocação de memória
-
Cálculo de tipos
- Ele quer oferecer suporte a cálculos sobre tipos para metaprogramação
- Como não quer criar codificação de tipo em tempo de execução em uma linguagem estaticamente tipada, e a utilidade de tipos em tempo de execução também é limitada, ele planeja isso apenas para tempo de compilação
- Ele acredita que algo parecido com C++ concepts também poderia ser implementado por chamadas em tempo de compilação, sem sintaxe separada
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
-
Corrotinas
- Adicionar
async/await no estilo Python ou JS está mais para um desejo do que para um plano
Planos para bibliotecas e módulos
-
Módulos
- Escrever todo o código em um único arquivo não é viável, então módulos são necessários
- Está planejando uma instrução simples como
import lib.sublib, que pode ser colocada em qualquer lugar do código e também segue as regras de escopo
- O escopo afeta apenas a visibilidade; o carregamento real acontece em tempo de compilação, e o ponto de entrada do módulo importado é executado antes do módulo atual
- O nome da biblioteca corresponde diretamente ao caminho no sistema de arquivos com base no caminho raiz especificado para o compilador ou interpretador
- Se for um único arquivo-fonte, importa apenas esse arquivo; se for um diretório, importa todos os arquivos desse diretório em alguma ordem
- É necessária uma sintaxe para apontar para arquivos no mesmo diretório, e está sendo considerada uma forma como
import .another
- Funções e variáveis globais importadas podem ser usadas sem prefixo e, em caso de ambiguidade, pode-se adicionar o prefixo do nome da biblioteca, como em
io.print(x)
- Está previsto que o ponto de entrada dos módulos seja executado em uma ordem determinística com base na ordem de importação e na ordenação topológica de imports recursivos, o que pode resolver os problemas de ordem de inicialização de C ou C++
- O layout de memória de programas com vários módulos ainda não foi decidido
- Pode-se dar a cada módulo um patch de memória separado e resolver chamadas de função e acessos a variáveis globais em tempo de execução, ou criar um único grande mapeamento de memória e usar offsets relativos
- Um único grande mapeamento pode ser mais rápido em tempo de execução, mas dificulta a compilação paralela de vários módulos
-
Prelude
- Quando houver módulos, será possível colocar utilitários básicos em um módulo prelude implicitamente incluído em todos os programas
- Entre os candidatos estão uma função
length() para arrays embutidos, uma interface de iterator, um tipo string view e ranges numéricos como o range(n) do Python
-
Literais de string
- Ainda não existem literais de string, e ainda não foi decidido que significado eles devem ter
- O plano é ter um tipo imutável
string_view no prelude, colocar o conteúdo das strings em algum lugar da memória executável e transformar o próprio literal em um string_view que aponta para essa memória
-
Biblioteca padrão
- Quando houver módulos, também será necessária uma biblioteca padrão
- O escopo que ele quer incluir abrange biblioteca matemática com vetores e matrizes, gerenciamento de memória no estilo
alloc/free vinculado a partir de libc, arrays dinâmicos, strings dinâmicas e formatação, tabelas hash, IO de console e arquivos, helpers de sistema de arquivos, helpers de tempo e relógio, e rede
Prioridade atual
- Ainda não está definido quando os recursos planejados serão implementados, nem se essa linguagem será usada de fato para mod de jogos ou outros fins
- Ele considera que não é bom tocar vários projetos ambiciosos seriamente ao mesmo tempo, e a prioridade atual continua sendo o desenvolvimento de jogos
- Como não dá para modificar um jogo antes que ele exista, o trabalho na linguagem está sendo feito quando dá vontade
1 comentários
Comentários no Lobste.rs
Os comentários aqui parecem muito mais severos do que eu esperava desta comunidade
É possível que outra linguagem, como Lua, já fosse suficiente. Também é possível que o autor tenha entrado num enorme yak shaving
Ainda assim, está claro que ele é muito competente e está se divertindo bastante, e o texto também tem conteúdo técnico interessante
Se for o texto de um colega nerd projetando mais uma linguagem de script para engine de jogo, eu leio com prazer. Se isso me poupar de mais um texto genérico gerado por IA dizendo que um SaaS lixo feito com vibecoding vai salvar o mundo e deixar o autor rico, eu leria mil textos desses por dia
A afirmação de que “Lua ou outra linguagem de script com compilação JIT é a opção padrão, mas fazer sandbox nelas é realmente difícil” é bem difícil de entender
Fazer sandbox em Lua é fácil, e esse é um dos seus maiores pontos fortes, com grandes vantagens que vão além de mods ou plugins. Nenhuma linguagem que eu vi chega perto nisso
A questão das versões do Lua até faz algum sentido, mas não vejo muita gente realmente ficando indignada com isso. A menos que você use Lua “moderno” para uma coisa e precise voltar para 5.1/5.2 por causa de outra, parece que a maioria usa só um ou só outro
Passa muito a impressão de uma pesquisa feita para justificar “eu quero criar minha própria linguagem”. E tudo bem, mas é melhor ser honesto do que fazer afirmações completamente erradas sobre as opções existentes
Se o interesse for em projeto de VM ou em partes mais de baixo nível, claro que a abordagem descrita no texto também serve. Mas isso está longe de ser a melhor forma de aprender design de linguagem
O exemplo mais fácil é escape por bytecode. Se você sabe que ele existe, pode desativá-lo, mas o fato de isso acontecer repetidamente revela um problema mais amplo. Você precisa entender como partes separadas da especificação do Lua interagem para montar as regras da sandbox; não é uma estrutura em que se possa compor programas com segurança a partir de elementos básicos claramente definidos quanto às interações adicionais que permitem
Um exemplo mais forçado é a poluição de protótipos entre ambientes diferentes dentro da mesma VM Lua. No Redis era possível poluir a metatable de
string, e então executar código com os privilégios de outro usuário do banco de dados que usasse recursos Lua. A superfície de poluição de protótipos do Lua é astronomicamente menor que a de algo como JavaScript, mas é engraçado que, mesmo havendo basicamente só 2 protótipos globais, ainda dê para fazer a mesma coisa com um delesAinda assim, o Luau tem uma solução bastante competente para esse problema, e não entendo por que, se o autor criar uma sandbox nova, isso implicaria evitar automaticamente todos esses mesmos problemas
A parte “meu jogo é extremamente focado em simulação. Eu simulo centenas de milhares de entidades com uma engine ECS customizada. O ideal seria que a linguagem de modding pudesse receber vários ponteiros de componentes e iterar sobre eles como um
forem C” poderia ter um ideal melhorEm especial, valeria comparar como engines de renderização como Unity, Unreal, Blender e Godot lidam com esse problema. Iteração externa não é rápida o bastante para falar em megapixels por segundo, e talvez também não sirva para dezenas de milhares de entidades por segundo. Aqui é preciso pensar em paralelismo
As grandes engines todas são amigáveis a GPU e em geral usam descrições em fluxo de dados de algoritmos sem ramificações e ridiculamente paralelizáveis. O autor pode não gostar de editores visuais, e esse sentimento é comum, mas isso não significa que a resposta seja um
forloopSe o autor tivesse mencionado que ECS é essencialmente um paradigma relacional e que a linguagem histórica carregada de bagagem a ser comparada aqui é SQL, talvez eu fosse mais generoso