1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • pslang bermula dari minat pada kemampuan modding untuk game berskala besar dan assembly yang dihasilkan kompiler C++, dan kini sudah cukup berfungsi untuk menulis Monte-Carlo path tracer sekitar 1.000 LOC
  • Bahasa modding memerlukan interoperabilitas C, penanganan array dan pointer tingkat rendah, sandboxing yang mudah, ukuran kompiler kecil, dan kompilasi cepat; sementara Lua dan mode native C++ masing-masing memiliki keterbatasan dari sisi penghubung performa, sandboxing, dan distribusi
  • pslang adalah bahasa tingkat rendah yang imperatif, dievaluasi secara eager, dan berbasis call-by-value, dengan sistem tipe statis, ketat, dan nominal, scope berbasis indentasi, array bawaan, tipe fungsi, pointer, serta tata letak memori yang dijamin
  • Kompilernya terbagi menjadi parser berbasis Bison, pemeriksaan tipe AST, IR, interpreter, dan JIT; saat ini target yang didukung hanya Aarch64 Mac, dan setelah pengenalan IR kualitas kode yang dihasilkan masih rendah karena belum ada register allocator
  • Implementasi saat ini berukuran sekitar 10.000 baris kode C++, dan ke depan sedang dipertimbangkan fitur seperti register allocator, optimisasi IR, interpreter IR, pembuatan executable, informasi debugging, polimorfisme, modul, dan standard library

Latar belakang pembuatan pslang

  • Setelah sekitar 17 tahun menulis program, muncul keinginan yang makin besar untuk membuat bahasa sendiri yang bukan sekadar mainan, melainkan punya kegunaan nyata sampai tingkat tertentu
  • Di masa lalu pernah membuat interpreter untuk bahasa-bahasa sulit seperti FALSE dan beberapa interpreter lambda calculus, tetapi itu tidak memuaskan keinginan untuk membuat bahasa yang “sesungguhnya”
  • Karena game berskala besar yang sedang dikembangkan memiliki struktur yang cocok untuk dimodding, saat memikirkan cara modding, bahasa pemrograman kustom muncul sebagai salah satu solusi yang sederhana
  • Pada Desember 2025, setelah mengikuti Advent of Compiler Optimisations dari Matt Godbolt, perhatian kembali tertuju pada assembly yang dihasilkan kompiler C++, dan muncul lagi keinginan untuk mengutak-atik assembly
  • Saat ini bahasanya masih jauh dari kualitas production, tetapi sudah cukup matang untuk menulis Monte-Carlo path tracer yang berfungsi dengan ukuran sekitar 1.000 LOC

Kebutuhan modding dan keterbatasan pilihan yang ada

  • Karena game mensimulasikan ratusan ribu entitas dengan engine ECS kustom, bahasa modding diharapkan bisa menerima sekumpulan pointer komponen dan mengiterasinya seperti loop for di C
  • Karena mod sulit dikendalikan, sandboxing harus mudah demi melindungi pemain, dan idealnya semua IO dan fungsi serupa bisa dinonaktifkan dengan satu sakelar
  • Modding harus cukup mudah sehingga cukup menaruh skrip di folder tertentu agar langsung bisa dipakai sebagai mod
  • Lua dan bahasa scripting JIT

    • Lua adalah pilihan standar, tetapi sandboxing tampaknya memerlukan kode pra-pemrosesan yang menghapus fungsi-fungsi IO dari standard library sebelum berhadapan dengan kode tak tepercaya, dan itu tidak terasa sebagai solusi yang andal
    • Lua adalah bahasa dinamis tingkat tinggi sehingga tidak bisa langsung memahami pointer C; akibatnya, untuk menghubungkan iterasi entitas ECS, setiap entitas harus bolak-balik native ↔ Lua ↔ native, atau entitas native harus dibentuk menjadi array Lua lalu diurai kembali
    • Standard Lua dan LuaJIT telah terpisah sejak beberapa versi lalu, yang dapat membingungkan baik modder maupun implementator
  • C++ dan mod native

    • Jika mod dibuat dengan C++, masalah iterasi entitas hilang, tetapi distribusi biner akan membutuhkan lingkungan pengembangan dan repositori artefak biner untuk semua platform
    • Jika didistribusikan sebagai source code, game harus menyertakan kompiler C++, dan instalasi LLVM standar saja memakan ruang disk 10~20 kali lebih besar daripada ukuran game saat ini
    • Jika DLL native mendeklarasikan dan memakai int open();, praktis mustahil memblokir akses filesystem atau jaringan, sehingga sandboxing tidak memungkinkan
    • Masalah yang sama juga berlaku untuk bahasa native lain seperti Rust
    • Walaupun modding adalah salah satu tujuan, masih belum pasti apakah bahasa ini benar-benar akan dipakai untuk modding game, dan tidak ingin membuatnya terlalu terspesialisasi untuk satu use case tertentu

Tujuan desain bahasa

  • Ingin menyediakan interoperabilitas C yang mulus agar penghubung antara kode game native dan kode modding sesederhana pemanggilan fungsi
  • Karena perlu menangani array entitas mentah, fitur tingkat rendah dibutuhkan
  • Bahasa ini harus praktis dan nyaman dipakai agar modder bisa menulis kode dengan tingkat kemudahan yang masuk akal
  • Sandboxing harus mudah, dan ukuran kompiler juga harus kecil
  • Karena tidak ingin memasukkan kompiler 1GB ke dalam game 50MB, jejak ukuran kompiler ingin ditekan
  • Kompilasi harus cepat agar pemain tidak menunggu lama saat mengompilasi mod, meski sebagian bisa diatasi dengan caching yang luas
  • Menginginkan cross-platform yang nyata, tetapi tetap menerima beberapa asumsi seperti beberapa platform desktop umum, 64-bit, dan dukungan IEEE754
  • Cukup jika performanya masuk akal cepat dibandingkan kebanyakan bahasa dinamis
  • Karena C++ sudah lama menjadi bahasa utama, pandangan tentang bahasa sangat dipengaruhi olehnya, tetapi sebisa mungkin tidak ingin sekadar membuat ulang C++

Model bahasa pslang saat ini

  • Nama kerjanya adalah pslang, diambil dari game engine psemek, dan ini adalah bahasa tingkat rendah yang imperatif, dievaluasi secara eager, dan menggunakan call-by-value
  • Sistem tipenya terdiri dari tipe statis, ketat, dan nominal
  • Contoh dasar berikut menggunakan fungsi, struct, tipe fungsi, dan pengembalian array sekaligus
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]

Scope dan tipe dasar

  • Menggunakan scope berbasis indentasi agar terlihat seperti bahasa scripting dan terasa lebih ramah bagi pemula
  • Saat ini indentasi memakai karakter tab, tetapi nantinya bisa saja berubah menjadi spasi
  • Body fungsi, body loop, body if, dan sebagainya membuat scope baru; fungsi dan struct bisa didefinisikan di dalam scope mana pun dan hanya terlihat di dalam scope tersebut
  • Fungsi lokal tidak bisa mengakses variabel dari scope tempat ia didefinisikan, jadi bukan closure, dan scope hanya memengaruhi resolusi nama
  • Scope tingkat atas diperlakukan seperti scope lain, dan mencakup entry point yang dijalankan saat file dimuat atau diinisialisasi
  • Tipe dasar berjumlah 13, yaitu bool, 4 jenis integer bertanda, 4 jenis integer tak bertanda, 3 jenis floating-point, dan unit
i8  i16  i32  i64
u8  u16  u32  u64
    f16  f32  f64
  • f8 tidak disertakan karena tidak didukung oleh kebanyakan CPU desktop dan belum ada kesepakatan tentang makna floating-point 8-bit
  • f16 mungkin kurang berguna bagi pengguna umum, tetapi sering dipakai di grafis untuk warna HDR, atribut vertex, dan sebagainya, dan sebagian besar CPU desktop modern mengimplementasikan IEEE754 f16, sehingga didukung secara bawaan
  • Semua aritmetika integer menggunakan two's complement dengan overflow, tanpa undefined behavior
  • unit hanya memiliki satu nilai, unit(), dan merupakan tipe pengembalian formal untuk fungsi yang tidak mengembalikan nilai
  • Fungsi yang tipe pengembaliannya dihilangkan otomatis mengembalikan unit, dan jika return di akhir fungsi semacam itu dihilangkan maka akan disisipkan otomatis
  • Jika fungsi bukan unit tetapi tidak mengembalikan nilai, itu adalah error

Literal, array, tipe fungsi, pointer

  • Literal angka 10 secara default bertipe i32, dan ukurannya ditentukan dengan sufiks seperti 10b, 10s, 10l
  • Literal tak bertanda menggunakan sufiks u, ditulis seperti 10ub, 10us, 10u, 10ul
  • Literal floating-point dengan titik desimal secara default bertipe f32, dengan 10.0h untuk 16-bit dan 10.0d untuk 64-bit
  • Bagian bilangan bulat atau pecahan tidak boleh dihilangkan seperti 10. atau .5; harus ditulis lengkap seperti 10.0, 0.5
  • Semua literal numerik memiliki tipe yang tidak ambigu
  • Array adalah tipe bawaan first-class, dan tidak seperti di C/C++, seluruh array bisa diteruskan ke fungsi, dikembalikan, atau saling ditugaskan
  • Ukuran array selalu diketahui saat waktu kompilasi, dan berperilaku seperti struct dengan beberapa field bertipe sama
  • Tipe array ditulis seperti i32[5], dan literal array seperti [1, 2, 3, 4, 5]
  • Tipe fungsi mirip dengan function pointer di C, ditulis dalam bentuk (a, b, c) -> d, dan jika argumennya satu, tanda kurung bisa dihilangkan seperti a -> b
  • Secara internal, tipe fungsi adalah function pointer biasa tanpa data yang ikut dibawa dan bukan closure
  • Tipe pointer ditulis seperti i32*, secara default merupakan pointer immutable, dan pointer mutable dideklarasikan sebagai i32 mut*
  • Alamat variabel ditulis &x, pointer mutable &mut x, dereferensi *p, dan aritmetika pointer digunakan seperti *(p + 10)

Struct, tata letak memori, tipe kosong

  • Struct dideklarasikan dengan keyword struct dan daftar field
struct string_view:
    size: u64
    data: u8*
  • Struct dibuat dengan konstruktor fungsional bawaan seperti string_view(10, data), dan field diakses dengan titik seperti v.x
  • Pada pointer ke struct, field juga bisa diakses dengan sintaks titik yang sama
  • Field struct tidak memiliki penanda mutabilitas terpisah; field dari objek mutable bersifat mutable dan field dari objek immutable bersifat immutable
  • Tidak ada access specifier, dan field selalu public
  • Semua objek memiliki tata letak memori yang dijamin; tipe dasar memiliki alignment yang sama dengan ukurannya, dan bool berukuran 1 byte
  • Tipe pointer dan fungsi selalu 64-bit dan memiliki alignment yang sama
  • Array memiliki alignment yang sama dengan elemennya, dan struct memiliki padding agar memenuhi kebutuhan alignment
  • Jaminan ini terutama ditujukan untuk menyederhanakan interoperabilitas C dan penggunaan dalam pemrograman GPU
  • unit dan struct tanpa field diperlakukan sebagai tipe kosong yang hanya memiliki satu nilai valid, dengan ukuran nyata 0 byte
  • Meneruskan tipe kosong ke fungsi, mendeklarasikannya sebagai variabel, atau menaruhnya sebagai field tidak memengaruhi penggunaan memori maupun ukuran struct
  • Tipe kosong bisa digunakan untuk hal seperti tag tingkat tipe saat waktu kompilasi
  • Baca/tulis melalui pointer ke tipe kosong belum diputuskan, dan saat ini aritmetika pointer pada tipe semacam itu tidak sah
  • Tidak mengikuti aturan seperti di C++ bahwa setiap objek harus memiliki alamat memori yang unik

Variabel, fungsi, alur kontrol, fungsi eksternal

  • Variabel immutable dideklarasikan seperti let x = 10, dan variabel mutable seperti mut x = 20
  • Pointer mutable tidak bisa dibuat untuk variabel immutable
  • Tipe bisa ditulis eksplisit seperti let x: i32 = 10, tetapi tidak wajib karena bahasa ini dirancang agar tipe semua ekspresi bisa diinferensikan tanpa ambigu
  • Semua variabel harus diinisialisasi
  • Fungsi ditulis dalam bentuk func foo(x: A, y: B) -> C: diikuti body, dan jika tipe kembalian dihilangkan maka nilainya unit
  • Semua fungsi mengikuti ABI C native dari platform eksekusi, sebuah keputusan agar mudah untuk interoperabilitas C, callback, dan penerusan sebagai function pointer ke hal seperti sistem ECS
  • Di dalam scope yang sama, urutan deklarasi fungsi dan struct bebas, sehingga fungsi atau struct yang dideklarasikan belakangan bisa digunakan lebih dulu
  • Karena semua argumen fungsi dan tipe kembalian harus ditulis lengkap, kebebasan urutan deklarasi ini tidak membuat inferensi tipe jadi lebih rumit
  • Terdapat pernyataan if/else if/else dan loop while, tetapi belum ada loop for
  • Bentuk ekspresi if digunakan seperti if A then B else C
  • Fungsi eksternal dideklarasikan seperti foreign func sin(x: f64) -> f64, dan implementasinya harus di-link dari tempat lain
  • Saat ini interpreter mencari fungsi semacam itu di executable interpreter itu sendiri melalui dlsym
  • Fungsi eksternal adalah mekanisme utama untuk interoperabilitas dengan library C dan library pihak ketiga, dan contoh raytracer menggunakan fitur ini untuk menghitung akar kuadrat, menulis file, mengukur waktu, dan membuat thread

Type casting dan operator

  • Tidak ada type casting implisit sama sekali; casting manual menggunakan operator as seperti (x as f32)
  • Semua tipe numerik bisa saling di-cast, dan semua tipe pointer juga bisa saling di-cast, kecuali mengubah pointer immutable menjadi pointer mutable
  • Tipe pointer bisa di-cast ke u64, dan u64 bisa di-cast ke tipe pointer
  • bool tidak bisa di-cast ke atau dari tipe apa pun
  • Sedang dipertimbangkan untuk menambahkan satu casting implisit dari T mut* ke T*
  • Operator standar seperti aritmetika, logika, dan perbandingan umumnya tersedia
  • &, |, &&, || bekerja pada boolean maupun integer; & dan | selalu mengevaluasi kedua operand, sedangkan && dan || menggunakan short-circuit evaluation
  • Operasi aritmetika dan perbandingan hanya bekerja pada pasangan dengan tipe numerik yang sama, tanpa promosi tipe numerik
  • Fitur bahasa saat ini mungkin belum terlihat banyak, tetapi sudah cukup untuk menulis program nyata dengan lumayan nyaman

Struktur compiler

  • Proyek dibagi menjadi beberapa library
    • types: definisi sistem tipe
    • ast: definisi abstract syntax tree dan utilitas
    • parser: parser
    • ir: intermediate representation
    • interpreter: interpreter
    • jit: compiler JIT
  • Rencananya interpreter dan compiler akan berupa aplikasi CLI sederhana yang menggunakan library-library ini, dan saat ini baru ada interpreter dalam mode JIT
  • Untuk meng-embed bahasanya, cukup gunakan library parser dan jit

Parser dan penanganan indentasi

  • Menggunakan Bison sebagai parser generator
  • Token didefinisikan di lexer grammar, dan tata bahasa bahasa ini didefinisikan di parser grammar
  • File adalah daftar pernyataan, dan pernyataan bisa berupa deklarasi fungsi, operator alur kontrol, deklarasi variabel, ekspresi, dan sebagainya, sementara ekspresi bisa berupa literal, variabel, operator, pemanggilan fungsi, dan lain-lain
  • Dalam grammar, beberapa konflik shift/reduce sempat harus diperbaiki, dan flag -Wcounterexamples dari Bison digunakan untuk melihat situasi tepat yang menyebabkan konflik
  • Menggunakan skeleton Bison lalr1.cc untuk menghasilkan kelas parser C++
  • Bison bawaan membuat parser C dengan status parser sebagai variabel global, tetapi itu tidak cocok untuk kasus seperti interpreter atau mode game yang perlu mem-parsing beberapa file secara paralel
  • Eksekusi Bison dimasukkan ke tahap build dalam CMake scripts
  • Keluaran parser adalah objek C++ yang merepresentasikan AST dari file yang diparse
  • Karena indentasi, grammar ini sebenarnya bukan context-free; apakah suatu pernyataan termasuk dalam body while bergantung pada jumlah token indentasi sebelumnya
  • Solusinya adalah mem-parse setiap baris sebagai pernyataan independen beserta tingkat indentasinya, lalu dalam linear pass sederhana menetapkan scope berdasarkan tingkat indentasi tersebut
  • Pendekatan ini memang agak hacky, tetapi bekerja dan sangat cepat, sehingga diterima
  • Pada pass yang sama, juga diperiksa bahwa break dan continue hanya muncul di dalam loop, return hanya di dalam fungsi, dan definisi field hanya di dalam struct

Pemeriksaan tipe dan interpreter

  • Pass pertama setelah parsing menyelesaikan semua identifier, dengan langsung menghubungkan node identifier ke node definisi variabel, fungsi, atau struct yang sesuai
  • Pass inti berikutnya memeriksa dan menginferensikan semua tipe
  • Inferensi tipe umumnya sederhana, dan terdiri dari pemeriksaan kondisi berdasarkan tipe node AST tertentu
  • Misalnya, tipe ekspresi di dalam if atau while harus bool, dan dua operand penjumlahan harus bertipe numerik yang sama atau salah satunya integer dan satunya pointer
  • Interpreter awal adalah tree-walking interpreter yang langsung mengunjungi node AST dan mengeksekusi konstruksi C++
  • Fungsi utamanya adalah exec() dan eval(), di mana exec() mengeksekusi satu pernyataan dan eval() menghitung lalu mengembalikan nilai dari satu ekspresi
  • Karena C++ bertipe statis, eval() mengembalikan variant untuk semua kemungkinan tipe nilai dalam bahasa tersebut
  • Struct direpresentasikan sebagai array pasangan nama-nilai, satu untuk setiap field, dan variant yang sama juga digunakan untuk menyimpan nilai variabel
  • Tujuan interpreter adalah menjalankan kode bahasa ini secara lintas platform dan membantu debugging implementasi serta program, bukan untuk dibuat cepat
  • Saat ini interpreter berada dalam kondisi sangat rusak, sehingga ada rencana untuk menulis ulang sepenuhnya berbasis IR
  • Interpreter lama tidak dapat menjalankan fungsi foreign
  • Fungsi foreign harus dipanggil dengan calling convention C, dan karena jumlah serta tipe argumen tidak dapat diketahui sebelumnya, kemungkinan diperlukan teknik vararg atau libffi
  • Interpreter dapat melakukan dump status internalnya, yaitu nama, tipe, dan nilai variabel, ke stdout, dan ini dulu merupakan cara utama untuk mendebug parser dan interpreter sebelum membuat compiler yang layak

Compiler JIT Aarch64 pertama

  • Pada awal Januari 2026 saat liburan, karena hanya membawa M1 Mac, arsitektur target compiler pertama menjadi Aarch64 Mac
  • Saat ini itu juga satu-satunya arsitektur yang didukung
  • Compiler ini menggunakan pendekatan JIT, dan hasilnya berupa blob memori yang dipetakan dengan bit executable serta pointer ke titik awal masing-masing fungsi
  • Struktur tingkat tingginya hampir menyerupai compiler berbasis stack tradisional, tetapi hasil ekspresi ditempatkan seperti cara fungsi dengan tipe return yang sama meletakkan nilainya di AAPCS64, yaitu calling convention C standar pada Aarch64 Mac
  • Integer dan pointer dikembalikan di register general-purpose x0, floating-point di register floating-point v0, dan struct dikembalikan di register atau stack tergantung ukurannya
  • Pendekatan ini mengurangi jumlah akses memori sehingga kode yang dihasilkan lebih cepat dan pemanggilan fungsi juga menjadi lebih sederhana
  • Stack terutama digunakan untuk hasil antara seperti operasi biner
(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
  • Struktur control flow diubah menjadi lompatan bersyarat, tetapi dalam kompilasi single-pass target lompatan belum diketahui karena body if atau while belum dikompilasi
  • Untuk mengatasinya, instruksi lompatan dengan offset 0 dikeluarkan terlebih dahulu, lalu setelah offset target diketahui, offset lompatan yang sebenarnya disisipkan
  • Cara yang sama juga diterapkan pada pemanggilan fungsi
  • Untuk menghasilkan instruksi CPU target, tidak digunakan library pihak ketiga; agar compiler tetap kecil, semuanya diimplementasikan sendiri
  • Implementasinya dilakukan dengan menelusuri instruction manual dan mengisikan bit-bit yang diperlukan

Bagian yang rumit di Aarch64

  • Semua instruksi di Aarch64 berukuran 32-bit sehingga tampak mudah ditangani, tetapi untuk memasukkan konstanta 32-bit ke register, dibutuhkan bit pemilih register, bit instruksi, dan bit konstanta, sehingga tidak muat dalam satu instruksi 32-bit
  • Konstanta 64-bit menjadi masalah yang lebih besar
  • Konstanta harus dirakit dari instruksi yang memuat potongan 16-bit pada offset 0, 16, 32, dan 48 bit, atau diletakkan di memori konstanta lalu dimuat dari sana
  • Untuk konstanta floating-point, digunakan cara memuat dari memori konstanta
  • Tidak seperti x86, tidak ada instruksi push/pop; yang ada adalah kombinasi instruksi untuk membaca/menulis antara register dan alamat memori serta menyesuaikan register alamat
  • Karena semua instruksi tepat 32-bit, harus terus memerhatikan apakah offset bertipe signed atau unsigned, apakah lebih dulu dikalikan konstanta tertentu, apakah register alamat dimodifikasi, dan hal serupa
  • Saat membaca dan menulis stack berdasarkan register SP, stack pointer harus selalu sejajar 16 byte
  • Offset yang tersedia dibatasi 12 bit, sehingga saat stack frame lebih besar dari kira-kira 16KB diperlukan kode khusus, tetapi ini belum diimplementasikan
  • Calling convention memiliki kasus khusus di mana struct dikirimkan atau dikembalikan lewat maksimal 2 register general-purpose, register floating-point, atau pointer memori, dan kode compiler harus menanganinya

Pengenalan IR dan compiler kedua

  • Setelah membuat interpreter dan compiler dasar, IR diperkenalkan untuk penggunaan ulang kode, menyederhanakan penulisan compiler untuk arsitektur lain, dan optimasi
  • IR awalnya mirip SSA, tetapi karena nilai dapat ditugaskan ulang ke node yang sama dan tidak menggunakan node phi, ini sebenarnya bukan SSA
  • IR adalah urutan node, dan tiap node merepresentasikan literal, operasi dengan node input, lompatan bersyarat/tanpa syarat, pemanggilan fungsi, dan sebagainya
  • Node yang merepresentasikan nilai juga menyimpan tipe dari nilai tersebut
  • Karena penugasan ulang diperbolehkan, ada instruksi IR assign untuk menetapkan ulang nilai node yang sudah ada
  • Lompatan bersyarat dibagi menjadi jump_if_zero dan jump_if_nonzero, karena biasanya ini dipetakan ke instruksi CPU yang berbeda dan lebih cepat daripada menegasikan nilainya lalu memakai instruksi kebalikannya
  • Karena mendukung function pointer, ada instruksi terpisah untuk memanggil node IR yang diketahui dan untuk memanggil nilai pointer yang tidak diketahui
  • Agar mudah menghapus atau menyisipkan node di posisi sebarang saat optimasi, node disimpan dalam std::list dan referensinya berupa iterator list
  • Literal nilai struct tidak bisa dibuat, jadi ada node alloc yang merepresentasikan nilai struct, dan biasanya dikompilasi dengan mengalokasikan ruang struct yang belum diinisialisasi di stack
  • Struct dibangun dengan menugaskan field-field individual
  • Jika field struct bertingkat seperti a.x.y direpresentasikan secara sederhana, a.x akan dibaca sebagai node baru lalu y dibaca dari node itu, sehingga sangat boros
  • a.x.y = b juga tidak efisien jika direpresentasikan sebagai t = a.x, t.y = b, a.x = t, sehingga IR menangani field bertingkat secara khusus
  • Node copy dapat mengekstrak field bertingkat sebarang dari struct, dan node assign dapat menugaskan ke field bertingkat sebarang dalam struct
  • Field bertingkat direpresentasikan sebagai array indeks seperti “ambil field nomor 0, lalu field nomor 2 di dalamnya, lalu field nomor 5 di dalamnya”
  • Setelah itu compiler Aarch64 ditulis ulang dengan membaginya menjadi compiler AST → IR dan compiler IR → Aarch64
  • AST → IR relatif sederhana, tetapi compiler IR → Aarch64 saat ini jauh lebih buruk kondisinya dibanding compiler lama berbasis stack
  • Pada awal fungsi, compiler ini mengalokasikan ruang stack sebanyak yang dibutuhkan untuk semua node IR fungsi tersebut, sehingga hampir semua nilai antara yang hidup singkat pun ikut memakan stack frame
  • Salah satu fungsi di raytracer harus dipecah menjadi dua agar stack frame-nya tetap muat dalam batas 12-bit yang disebutkan sebelumnya
  • Compiler ini diasumsikan akan menggunakan register allocator, sehingga kode hasilnya diperkirakan akan membaik beberapa orde besaran setelah itu

Rencana compiler dan interpreter

  • Implementasi saat ini terdiri dari sekitar 10.000 baris kode C++, dan ia puas bahwa compiler ini kecil menurut standar modern serta benar-benar berfungsi
  • Register allocator

    • Compiler IR → Aarch64 saat ini benar-benar membutuhkan register allocator
    • Ia berencana menggunakan allocator linear scan standar sebagai kompromi antara kecepatan kompilasi dan kualitas kode
  • Optimisasi IR

    • Ia ingin menambahkan propagasi konstanta, penyederhanaan aritmetika, penghapusan dead code, inlining, dan loop unrolling berbasis IR
    • Tujuannya bukan mengalahkan GCC atau LLVM, tetapi ia ingin fungsi sederhana seperti penjumlahan vektor 3D dikompilasi menjadi sesedikit mungkin instruksi CPU
  • Interpreter IR

    • Ia berencana menulis ulang interpreter dengan pendekatan evaluasi IR langsung, yang menurutnya akan membuat interpreter menjadi jauh lebih sederhana
  • Pembuatan file eksekusi

    • Compiler saat ini hanya membuat blob memori JIT untuk langsung dijalankan
    • Ia juga ingin membuat biner yang dapat dieksekusi dalam format spesifik platform, sehingga perlu mendalami spesifikasi format biner seperti ELF, Mach-O, dan PE
    • Salah satu tujuannya juga adalah mencoba membuat file eksekusi sekecil mungkin
  • Debugging

    • Ia sudah cukup sering menelusuri assembly yang dibuat JIT di lldb, dan ingin bisa melakukan debugging bahasa itu sendiri dengan baik
    • Untuk itu kemungkinan besar dibutuhkan dukungan format informasi debug DWARF, dan saat ini ia hampir tidak tahu apa-apa tentangnya

Fitur bahasa yang ingin ditambahkan

  • Konstruktor struct

    • Saat ini struct hanya bisa dipakai dengan mengisi semua field seperti vec3i(1, 2, 3) atau menginisialisasinya ke nol seperti vec3i()
    • Ia mempertimbangkan pendekatan di mana mendeklarasikan fungsi dengan nama yang sama seperti struct akan membuatnya bertindak sebagai konstruktor arbitrer
func vec3i(x: i32, y: i32) -> vec3i:
    return vec3i(x, y, 0)
  • Namun, ia belum memutuskannya karena mungkin lebih baik memberi nama unik pada fungsi semacam ini
  • Variabel global

    • Saat ini variabel global belum didukung
    • Ia berencana membuat variabel global dengan kata kunci global, dan aksesnya tetap dibatasi aturan scope sehingga dimungkinkan membuat variabel global lokal-fungsi seperti variabel static di C
    • Variabel tingkat teratas bukan global sungguhan kecuali menggunakan global, melainkan variabel lokal dari fungsi entry point file
    • Struktur ini bisa membingungkan pengguna, jadi ia juga sedang mempertimbangkan pilihan lain
    • Karena Mac tidak mengizinkan pemetaan memori yang dapat ditulis dan dieksekusi sekaligus, variabel global mungkin perlu dialokasikan terpisah dari kode dan dipetakan dengan flag yang berbeda
    • Akses global mungkin perlu dilakukan dengan alamat yang diresolusikan saat runtime, bukan offset yang diketahui saat compile time
    • Namun tampaknya mprotect() bisa dipakai untuk mengubah flag pada sebagian pemetaan, jadi ia berencana mencoba itu terlebih dahulu
  • Sintaks pemanggilan metode

    • Demi keterbacaan, ia ingin x.f(y) jika memungkinkan berarti f(&x, y) atau f(&mut x, y)
  • Polimorfisme

    • Ia menganggap ini sebagai fitur potensial yang paling penting
    • Pilihan yang paling kuat adalah overloading fungsi gaya C++ dengan template fungsi dan template struct tanpa batasan, atau trait eksplisit gaya Haskell/Rust dengan fungsi dan struct generik yang dibatasi trait
    • Gaya C++ lebih kuat, lebih mudah dibaca dalam kasus sederhana, dan lebih mudah diimplementasikan di compiler, tetapi pesan error-nya bisa menjadi sangat sulit dipahami
    • Trait eksplisit lebih mudah dibaca dalam beberapa kasus dan menyelesaikan masalah pesan error, tetapi memerlukan sistem baru berupa trait dan trait bound sehingga implementasi compiler menjadi lebih sulit
    • Ia belum memutuskan, tetapi meskipun awalnya tidak ingin membuat ulang C++, ia sangat condong ke pilihan pertama
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
  • Ia juga menginginkan inferensi argumen fungsi jika memungkinkan
  • Overloading operator

    • Ini membutuhkan polimorfisme dalam bentuk apa pun
    • a + b bisa menjadi cara untuk memanggil fungsi overload seperti add(a, b) atau metode trait seperti Add::add
  • Loop for

    • Karena sudah bisa ditiru dengan while, ia berencana menjadikan for sebagai loop berbasis koleksi seperti range-based loop di C++ atau loop Python
    • Untuk itu dibutuhkan interface range/iterator, dan sekali lagi dibutuhkan polimorfisme
  • Manajemen sumber daya otomatis

    • Ia menilai bahasa yang praktis dan nyaman dipakai memerlukan cara untuk membantu pelepasan sumber daya seperti memori, file, socket, dan mutex
    • Kandidatnya adalah RAII dan move gaya C++, defer gaya Zig, dan linear type
    • RAII bersifat implisit sehingga punya kelemahan berupa penambahan perintah dan alur kontrol tersembunyi
    • defer bersifat eksplisit, tetapi harus ditulis sendiri setiap kali, tidak bisa mencegah kelupaan, dan tidak nyaman saat melepaskan koleksi bertingkat seperti array file
defer free(array)
defer for file in array:
    close(file)
  • Linear type tampak menjanjikan karena dapat mempertahankan sifat eksplisit dari pemanggilan manual free atau close sambil memaksa objek dikonsumsi oleh fungsi pelepas sumber daya
  • Namun, ini sulit dikombinasikan dengan koleksi bertingkat seperti array file dinamis, jadi ia belum memutuskannya
  • Literal polimorfik

    • Array kosong [] memang diketahui berukuran 0, tetapi tipe elemennya tidak bisa diinferensikan
    • null bisa menjadi tipe pointer apa pun, dan literal inf yang ingin ia tambahkan bisa menjadi tipe floating-point apa pun
    • Sebagai solusi, ia mempertimbangkan tiga opsi: literal polimorfik ala Haskell, tipe built-in/pustaka khusus dengan konversi implisit seperti nullptr_t di C++, atau literal khusus di AST dengan penanganan ad-hoc oleh compiler
    • Saat ini ia cenderung pada pendekatan terakhir, yaitu hanya mengizinkan null di tempat yang sudah mengetahui tipe pointer yang diharapkan, seperti inisialisasi variabel bertipe eksplisit atau saat diberikan sebagai argumen fungsi
    • Pendekatan ini paling sederhana, tetapi tidak skalabel karena tidak memungkinkan pembuatan tipe kustom dari null
  • Evaluasi saat compile time

    • Ia ingin mendeklarasikan variabel compile time dengan kata kunci const, dan memungkinkan penggunaannya dalam ekspresi compile time seperti ukuran array
    • Nilai const tidak bisa di-assign ulang dan alamatnya tidak bisa diambil
    • Fungsi yang sesuai dapat dipanggil dalam ekspresi compile time jika tidak memiliki akses variabel global atau efek samping
    • Isi fungsi akan berjalan seperti fungsi biasa, tetapi dieksekusi saat kompilasi dan hasilnya menjadi ekspresi compile time
    • Diperlukan mekanisme untuk menandai fungsi foreign yang aman dipanggil saat compile time, seperti fungsi matematika atau alokasi memori
  • Perhitungan tipe

    • Ia ingin mendukung perhitungan atas tipe untuk metaprogramming
    • Karena ia tidak ingin membuat encoding tipe runtime dalam bahasa bertipe statis dan kegunaan tipe runtime juga terbatas, ini direncanakan hanya untuk compile time
    • Ia juga menilai fitur mirip C++ concepts dapat diimplementasikan sebagai pemanggilan compile time tanpa sintaks terpisah
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
  • Coroutine

    • Menambahkan async/await gaya Python atau JS lebih dekat ke harapan daripada rencana nyata

Rencana pustaka dan modul

  • Modul

    • Menulis semua kode dalam satu file tidak realistis, jadi modul diperlukan
    • Direncanakan sintaks sederhana seperti import lib.sublib, yang bisa diletakkan di mana saja dalam kode dan tetap mengikuti aturan scope
    • Scope hanya memengaruhi visibilitas, sedangkan pemuatan yang sebenarnya terjadi saat waktu kompilasi, dan entry point modul yang diimpor akan dijalankan sebelum modul saat ini
    • Nama pustaka akan dipetakan langsung ke path filesystem berdasarkan root path yang ditentukan ke compiler atau interpreter
    • Jika berupa satu file sumber, hanya file itu yang diimpor; jika berupa direktori, semua file di direktori tersebut akan diimpor dalam suatu urutan
    • Diperlukan sintaks untuk menunjuk file dalam direktori yang sama, dan bentuk seperti import .another sedang dipertimbangkan
    • Fungsi dan variabel global yang diimpor bisa digunakan tanpa prefiks, dan jika ambigu, prefiks nama pustaka seperti io.print(x) bisa ditambahkan
    • Entry point modul akan dijalankan dalam urutan deterministik yang ditentukan oleh urutan import dan pengurutan topologis untuk import rekursif, sehingga bisa menyelesaikan masalah urutan inisialisasi seperti di C atau C++
    • Tata letak memori untuk program dengan banyak modul masih belum diputuskan
    • Tiap modul bisa memiliki patch memori terpisah dan panggilan fungsi serta akses variabel global diresolusikan saat runtime, atau bisa juga dibuat sebagai satu pemetaan memori besar dan memakai offset relatif
    • Satu pemetaan besar bisa lebih cepat saat runtime, tetapi membuat kompilasi paralel untuk banyak modul menjadi lebih sulit
  • Prelude

    • Jika modul sudah ada, utilitas dasar bisa ditempatkan dalam modul prelude yang secara implisit disertakan di semua program
    • Kandidatnya mencakup fungsi length() untuk array bawaan, antarmuka iterator, tipe string view, dan numeric range seperti range(n) di Python
  • Literal string

    • Literal string masih belum ada, dan belum diputuskan semantik seperti apa yang seharusnya dimilikinya
    • Rencananya adalah menaruh tipe string_view immutable di prelude, menempatkan isi string di suatu bagian memori executable, lalu mengubah literal itu sendiri menjadi string_view yang menunjuk ke memori tersebut
  • Pustaka standar

    • Jika modul sudah ada, pustaka standar juga diperlukan
    • Cakupan yang ingin disertakan adalah pustaka matematika termasuk vektor dan matriks, manajemen memori bentuk alloc/free yang di-link dari libc, array dinamis, string dinamis dan formatting, hash table, IO konsol dan file, helper filesystem, helper waktu dan jam, serta networking

Prioritas saat ini

  • Belum diputuskan kapan fitur-fitur yang direncanakan akan diimplementasikan, atau apakah bahasa ini akan benar-benar dipakai untuk modding game atau tujuan lain
  • Penulis menganggap tidak baik menjalankan beberapa proyek ambisius secara serius pada saat yang sama, dan prioritas saat ini tetap pengembangan game
  • Karena game tidak bisa dimodifikasi sebelum game itu sendiri dibuat, pengerjaan bahasa ini dilakukan saat ada keinginan saja

1 komentar

 
GN⁺ 4 jam lalu
Opini Lobste.rs
  • Komentar-komentar di sini terasa jauh lebih kejam daripada yang saya harapkan dari komunitas ini
    Mungkin saja bahasa lain seperti Lua sebenarnya sudah cukup. Mungkin juga penulis terjebak dalam yak shaving besar-besaran
    Meski begitu, jelas dia sangat mahir dan sangat menikmati prosesnya, dan ada juga isi teknis yang menarik di dalam tulisannya
    Kalau ini tulisan sesama nerd yang sedang merancang bahasa skrip lain untuk game engine, saya dengan senang hati akan membacanya. Jika itu bisa menggantikan satu tulisan sampah hasil AI tentang SaaS buatan vibecoding yang katanya akan menyelamatkan dunia dan membuat penulisnya kaya, saya bisa membaca seribu tulisan seperti ini setiap hari

  • Klaim bahwa “Lua atau bahasa skrip berkompilasi JIT lain adalah pilihan standar, tetapi sandboxing-nya sangat sulit” benar-benar sulit dipahami
    Sandboxing di Lua itu mudah adalah salah satu keunggulan terbesarnya, dan itu memberi manfaat besar di luar mod atau plugin. Tidak ada bahasa lain yang pernah saya lihat yang mendekati ini

    • Seluruh paragraf itu terbaca seperti, “Saya pernah membaca sedikit tentang bahasa ini, tapi meskipun ini sudah jadi pilihan standar selama 20 tahun terakhir, saya tidak berniat meluangkan beberapa jam untuk menelitinya”
      Soal masalah versi Lua memang ada benarnya sampai tingkat tertentu, tetapi saya jarang melihat orang benar-benar frustrasi besar karenanya. Kecuali kalau seseorang memakai Lua yang “modern” untuk suatu keperluan lalu harus turun ke 5.1/5.2 untuk pekerjaan lain, kebanyakan orang tampaknya hanya memakai salah satu saja
    • Cukup aneh bahwa “kemungkinan yang umum” sejak awal hanya Lua dan C++. Memangnya kategori bahasa yang ada cuma dua macam?
      Kuat terasa seperti riset yang dilakukan untuk membenarkan “saya ingin membuat bahasa saya sendiri”. Itu sendiri tidak masalah, tetapi lebih baik jujur daripada membuat klaim yang sepenuhnya keliru tentang opsi yang sudah ada
    • Hal lain yang mengganggu dari tulisan ini adalah bahwa jika ingin belajar desain bahasa, jauh lebih baik menulis compiler untuk bahasa host yang menargetkan mesin virtual atau runtime yang sudah ada, daripada turun sampai ke dasar semuanya
      Tentu, kalau yang diminati adalah desain VM atau bagian yang lebih low-level, pendekatan yang dijelaskan di tulisan itu tetap masuk akal. Tetapi itu jauh dari cara terbaik untuk mempelajari desain bahasa
    • Game-game buatan programmer hebat pun cukup sering mengalami pelarian dari sandbox Lua. Factorio, Binding of Isaac, dan kalau kita menganggap cloud programming sebagai permainan aneh yang selalu dimenangkan semua orang, ~~Redis~~ juga begitu, jadi saya curiga ada sesuatu yang salah dengan cara API disajikan
      Contoh paling mudah adalah pelarian lewat bytecode. Kalau tahu keberadaannya, itu bisa dinonaktifkan, tetapi fakta bahwa hal seperti ini terus berulang menunjukkan masalah yang lebih luas. Untuk menyusun aturan sandboxing, kita harus memahami bagaimana bagian-bagian spesifikasi Lua yang terpisah saling berinteraksi; strukturnya bukan sesuatu yang memungkinkan komposisi program yang aman dari elemen-elemen dasar yang jelas tentang interaksi tambahan apa yang mereka izinkan
      Contoh yang lebih dipaksakan adalah pencemaran prototipe di antara lingkungan berbeda dalam VM Lua yang sama. Di Redis, metatable milik string bisa dicemari, dan itu memungkinkan eksekusi kode dengan hak pengguna basis data lain yang memakai fitur Lua. Permukaan pencemaran prototipe di Lua jauh lebih kecil secara astronomis dibanding JavaScript, tetapi lucu juga bahwa meskipun prototipe globalnya praktis cuma ada 2, salah satunya tetap bisa dipakai untuk melakukan hal yang sama
      Meski begitu, Luau punya solusi yang cukup kompeten untuk masalah ini, dan saya kurang paham kenapa penulis menganggap bahwa kalau ia membuat sandbox baru, semua masalah yang sama itu otomatis bisa dihindari secara implisit
  • Bagian “Game saya sangat berat pada simulasi. Saya mensimulasikan ratusan ribu entitas dengan custom ECS engine. Idealnya, bahasa modding itu bisa menerima beberapa pointer komponen dan mengiterasinya seperti for loop di C” seharusnya bisa punya ideal yang lebih baik
    Khususnya, layak membandingkan bagaimana engine rendering seperti Unity, Unreal, Blender, dan Godot menangani masalah ini. Iterasi eksternal tidak cukup cepat untuk membicarakan megapiksel per detik, dan mungkin juga tidak cocok untuk puluhan hingga ratusan ribu entitas per detik. Di sini kita perlu memikirkan paralelisme
    Engine-engine besar semuanya ramah GPU dan biasanya memakai deskripsi dataflow dari algoritma tanpa percabangan yang bisa diparalelkan dengan sangat mudah. Penulis mungkin tidak suka editor visual, dan pemikiran seperti itu memang umum, tetapi itu bukan berarti for loop adalah jawabannya
    Kalau penulis menyebutkan bahwa ECS pada dasarnya adalah paradigma relasional, dan bahasa pembanding yang semestinya dipakai adalah SQL, bahasa dengan banyak beban sejarah, mungkin saya akan melihatnya dengan lebih murah hati