StingType
StingType is a programming language made to take beginners who know nothing about programming straight to low-level code — pointers, memory, bits — without the silent, confusing mistakes that make C hard to learn.
gstc (the General StingType Compiler) is a standalone compiler: it
turns a .st program straight into a native executable for Linux or
Windows (x86, 64- and 32-bit), 64-bit ARM Linux, or a signed, ready to
install Android app. It writes the machine code (x86-64, i386 or
ARM64) and the executable file itself — it never runs another compiler,
assembler, or linker, and it uses no outside code (an Android app needs
no Android SDK either). Its runtime (float printing, files, arguments) and
its math (MathLib) are StingType, carried inside gstc and compiled like
any other program.
print_str("Hello from StingType!")
GSTC 0.95 (prerelease). The language is frozen for 1.0 -- see ROADMAP-1.0.md. New to StingType? Start with TUTORIAL.md.
Quick start
Download gstc (Linux) or gstc.exe (Windows) and run it — nothing else
to install:
gstc examples/hello.st -o hello
./hello
On Windows, gstc examples\hello.st -o hello makes hello.exe. Either
system can build for the other: --target=windows64 or --target=linux64,
and both build 32-bit programs too: --target=linux32, --target=windows32.
Programs with no operating system at all (kernels and the like) are
--target=freestanding-x64 and --target=freestanding-32. 64-bit ARM Linux (a Raspberry Pi with a 64-bit OS, ARM
servers) is --target=linux-arm64, and an Android app is
--target=android:
gstc game.st --target=android --assets=assets # game.apk, signed, ready to install
The binaries package has four compilers: gstc and gstc.exe (64-bit
Linux and Windows) and gstc32 and gstc32.exe (for 32-bit Linux and
Windows PCs). Each builds programs for its own kind of machine by
default, and any of them can build for any target.
Building gstc itself
gstc is written in C, so building gstc from source needs a C compiler, once (programs gstc builds never do):
cc -std=c11 -O2 -o gstc src/*.c
tools/embed_sources.py re-embeds lib/mathlib.sth and
src/runtime.sth into gstc after either is edited.
What gstc covers
Everything in the language: integers, floats, bool, strings, structs
(laid out byte-for-byte like C's), arrays, slices, pointers, generics,
enums, globals, goto, file I/O, stdin, program arguments, the standard
library, MathLib, and every safety check, with the full chain of calls
in error reports. Only the functions a program actually reaches are
compiled into it, so a small program stays small (about 20 KB).
Raw power tools
StingType is low-level on purpose: these need no keyword, no unsafe
block, and no special mode. They are simply part of the language,
available anywhere — while ordinary code stays checked by default.
// pointer arithmetic, C-style
var p: *u8 = buf + 4
var distance: i64 = p - buf // in elements, like C
// real Linux system calls -- nothing standing in between
var written: i64 = syscall3(1, 1, str_ptr(msg) as u64, str_len(msg))
var addr: u64 = syscall6(9, 0, 4096, 3, 34, 0, 0) as u64 // mmap
// raw memory, at any width
call poke32(addr, 4000000000)
print_i64(peek32(addr) as i64)
// exact instruction bytes, spliced straight into the program
asm_bytes(0x0F, 0x05)
syscall0–syscall6 are Linux-only; using one while targeting Windows
is a compile error, not a silent wrong answer (Windows has no stable
syscall numbers). For inline machine code, asm_bytes(...) places exact
instruction bytes; asm("...") assembly text isn't available, since
gstc carries no assembler.
Low-level tools
No special mode or keyword — these are simply part of the language:
| Tool | What it does |
|---|---|
p + n, p - n, p - q |
pointer arithmetic, scaled by element size (like C) |
addr as *T, p as u64 |
turn integers into pointers and back |
peek8/16/32/64(addr) |
read memory at any address |
poke8/16/32/64(addr, v) |
write memory at any address |
syscall0(nr) … syscall6(nr, a1, …) |
a direct Linux system call (not available on Windows) |
asm_bytes(0x90, …) |
put these exact machine-code bytes right here |
port_in8/16/32(port), port_out8/16/32(port, v) |
x86 I/O ports (in/out) |
str_ptr(s), str_len(s) |
a string's raw bytes and length |
var buf: *u8 = alloc(16) as *u8
call poke32(buf as u64, 305419896)
print_i64(peek8(buf as u64) as i64) // 120 (0x78, the low byte)
var msg: str = "hello from a raw syscall\n"
call syscall3(1, 1, str_ptr(msg) as u64, str_len(msg)) // write(1, ...)
Options
| Option | What it does |
|---|---|
-o name |
name of the program to create |
--check |
only look for mistakes — don't build anything |
--watch |
if there are mistakes, wait: fix them and save, and gstc checks again by itself, building once everything is right |
--dbg |
log everything: each step gstc takes (with timings), every warning and error |
--target=... |
native (default), linux64, windows64 (alias windows), linux32, windows32, linux-arm64, android (and the freestanding ones) |
--release |
as fast as it gets, like C: no runtime checks (integers wrap, indexes and casts aren't checked) |
--release-checked |
fast, but every runtime check stays; only the chain of calls is left out of error reports |
--base=, --stack= |
freestanding: where it loads, and its stack -- see Freestanding programs |
A tour of the language
// variables: the type is worked out for you, or you can say it
var count = 10
var ratio: f64 = 0.5
const MAX = 100
// functions
fn add(a: i32, b: i32): i32
return a + b
endfn
// decisions and loops
if count > 5 then
print_str("big")
elseif count > 0 then
print_str("small")
else
print_str("none")
end
for i = 1, 10 do
print_i32(i)
end
var nums: [4]i32 = [3, 1, 4, 1]
for n in nums do
print_i32(n)
end
// structs and enums
struct Point
x: i32
y: i32
endstruct
enum Direction
North
South
endenum
var p: Point = Point(x: 1, y: 2)
var d: Direction = Direction.South
// generics: write it once, use it for any type
fn biggest<T>(a: T, b: T): T
if a > b then
return a
end
return b
endfn
struct Pair<A, B>
first: A
second: B
endstruct
var pair = Pair(first: 1, second: 2.5) // Pair<i32, f64>
// pointers and bits
var x: i32 = 7
var ptr: *i32 = &x
print_i32(*ptr)
var flags: u8 = 12
print_bool(flags & 4 != 0) // bitwise ops bind tighter than ==
// the standard library, under a name of your choosing
include "lib/standard.sth" as std
call std.sort(nums)
print_i32(std.max(3, 7))
Types: i8 i16 i32 i64, u8 u16 u32 u64, usize isize (pointer-sized), f32 f64, bool, str,
pointers *T, fixed arrays [N]T, slices []T, structs, unions, and enums.
The full rules are in SPEC.md.
More of the language
// choose: one case runs, no falling through; checked while compiling
choose ch
is 'a' to 'z', 'A' to 'Z' then // ranges: `to` includes the end,
kind = 1 // `until` stops before it
is '0' to '9' then
kind = 2
else
kind = 0
end
// (an enum with no `else` must list every variant; a value listed twice
// or overlapping ranges are errors)
// pieces of strings, arrays and slices -- bounds-checked
var word = line[0 until 5]
var middle = nums[2 to 4]
// compound assignment
total += price * count
flags |= 0x80
// characters are numbers: the byte, or the Unicode code point
var c: u8 = 'A'
// layout: unions, packed structs, and sizes known while compiling
union Number
i: i64
f: f64
endunion
packed struct Wire
tag: u8
size: u32
endstruct
verify sizeof(Wire) == 5, "Wire must match the protocol" // proven, no code
var at = offsetof(Wire, size) // 1
var al = alignof(Number) // 8
// hidden: only this file can use it (libraries' helpers)
hidden fn helper(x: i32): i32
return x + 1
endfn
// `: new T` -- the result is newly made, never part of the arguments
fn copy_text(s: str): new str
...
endfn
stop 3 // end the program now, exit status 3
halt("config file is missing") // stop it as a runtime error, with the chain of calls
Arithmetic on two plain numbers (KIB * 64, (1 << 12) - 1) is worked
out while compiling, unless it would stop the program — then it is left
in, so it still stops with its usual message.
Threads and atomics
fn work(arg: *u8): i64
call atomic_add(arg as *i64, 1) // one indivisible step
return 0
endfn
var hits: i64 = 0
var t = thread_start(work, &hits as *u8) // its own 8 MiB stack
call thread_wait(t) // gives work's result
atomic_load, atomic_store, atomic_swap, atomic_add,
atomic_compare_swap, atomic_fence and cpu_relax work on any integer
or pointer. threads.sth adds Mutex. The heap is safe to use from
every thread. A runtime error on a thread shows that thread's chain of
calls, and a thread running out of stack is caught like any other.
Threads work on 64-bit Linux and Windows.
The libraries
Each one is built into gstc (no files to ship) and also lives in lib/:
| Library | What's in it |
|---|---|
standard.sth |
I/O, strings, math on any number type, slices; parse_int<T>, parse_f64, checked_add/sub/mul |
collections.sth |
List<T> (a growable array), Map<V> (text keys), IntMap<V>; map_value/intmap_value give a value or a fallback |
text.sth |
Text (a string builder), fill("@ of @", [a, b]) (or fill2("@ of @", a, b)), split, join, padding, UTF-8 |
time.sth |
now_ns, now_ms, unix_time, sleep_ms, Date, date_text |
system.sth |
environment variables, running programs (run, run_output), folders and files |
net.sth |
TCP and UDP over IPv4; host_lookup (names to addresses: DNS, /etc/hosts, the Windows resolver); UPnP (upnp_find, upnp_open_port: a home router forwards a port by itself); http_request |
sound.sth |
WAV files, up to 32 at once, mixed in StingType (ALSA on Linux, waveOut on Windows): play, play_loop, stop_sound, set_volume, update |
threads.sth |
Mutex, mutex_lock/try_lock/unlock, thread_yield |
testing.sth |
expect, expect_int, expect_str, expect_f64 for gstc test |
mathlib.sth |
MathLib (below) |
random.sth |
Rng (xoshiro256**: fast, the same numbers for the same seed on every platform): rng_range, rng_below, rng_f64, rng_chance, rng_normal, rng_shuffle, rng_pick; random_bytes/random_u64/rng_seed_os -- secure randomness from the operating system, for keys and tokens |
hash.sth |
SHA-256 (in one go or piece by piece), HMAC-SHA256, CRC-32, Adler-32, FNV-1a |
encoding.sth |
Base64, Base64url and hex, both ways (decoding refuses anything malformed) |
json.sth |
reading JSON (strict RFC 8259; values by node number, errors with line and column; nesting past 512 refused) and writing it (JsonWriter: commas and escapes taken care of) |
regex.sth |
regular expressions: classes, groups, alternatives, counted and lazy repeats, anchors, \b, (?i) (?m) (?s); regex_find, regex_full, regex_replace_all ($1), regex_split. Never backtracks (a Pike VM): no pattern or text can make it hang |
include "collections.sth"
include "text.sth"
var names: List<str>
list_add(&names, "ada")
list_add(&names, "linus")
var ages: Map<i32>
map_set(&ages, "ada", 36)
var line = fill("@ is @ years old", [list_get(&names, 0), "36"])
print_str(line)
str_free(line)
Libraries that use libraries
A library includes what it needs under a name of its own -- json.sth
has include "standard.sth" as json_std -- and your program can still
include standard.sth as std, or plainly: one file under several
names is still one library (its code once, its globals shared), and
every name reaches it.
How gstc makes code fast
Two kinds of build. An ordinary build checks everything -- every
integer operation, index, slice and cast -- and a failure stops the
program with the line and the chain of calls. --release is the C way:
no runtime checks at all, so integers wrap around, an index past the end
reads whatever is there, and you have full control. Test with ordinary
builds; ship with --release (or --release-checked, which keeps the
checks and drops only the chain of calls). Either way, gstc makes the
code cheaper:
- Constants folded: a
constwith a number or bool value becomes that value where it's used, casts of constants are worked out while compiling, and arithmetic or comparisons against a constant use the instruction's immediate form. - Division by a constant without the divide instruction (a multiply and a shift), with exactly the divide's results.
- Small functions inlined, on every target: a function that is a
few
var x = ...lines and areturn(plain numbers, bools or pointers in and out) is put where it's called -- no call, no frame. Error reports still name it: a check that fails inside it saysin rotr ... called from compress, at line 12. A constant argument stays a constant inside it (rotr(x, 7)shifts by an immediate 7, and32 - kis worked out while compiling). - Floats in registers (64-bit x86 and ARM64): a float expression
of arithmetic,
sqrt_f64, negation and values read from variables, fields and elements is worked out in the CPU's float registers directly, each value loaded straight where it's needed. The results are bit for bit the same. - Fewer trips through memory: on ARM64 the values waiting inside an expression stay in registers (x23-x27) instead of the frame; on x86-64 the left side of an operator waits in r9-r11 instead of on the stack when the right side can't disturb them; register variables are read straight from their registers; a named array is indexed without a push; field loads take the field's offset in the instruction.
Measured against the previous build (same programs, same output):
| Program | 64-bit x86 | ARM64 | 32-bit x86 |
|---|---|---|---|
| SHA-256 (hash.sth) | 1.56 s -> 1.02 s | 43% fewer instructions | 2.61 s -> 1.69 s |
| n-body (floats) | 30% fewer instructions | 34% fewer instructions | -- |
| BlockWorld's world generation | 9% fewer instructions | about 4% faster | about 8% faster |
Tools
| Command | What it does |
|---|---|
gstc new NAME |
start a project: project.sting, src/, a test |
gstc build [dir] |
build the project for every target it lists |
gstc test [file.st] |
run each fn test_...() on its own, with what failed and where |
gstc fmt [--check] files |
lay the code out the standard way |
gstc doc lib.sth |
a Markdown reference from the comments |
gstc lsp |
a language server: mistakes shown in your editor as you type |
Programs built for Linux carry their function names and a line table
(DWARF), so gdb shows crash () at main.st:11 and steps line by line.
When something's wrong
Every error and warning names the file, line and column, shows the line, and highlights exactly what's wrong — and gstc stops before building anything, so a mistake never becomes a program:
test.st:8:26: type error: undeclared variable 'scroe'
8 | print_f64(average(score, scroe))
| ^~~~~
test.st:3:7: warning: the variable 'unused' is never used
3 | var unused: i32 = 5
| ^~~~~~
Warnings (an unused variable, code after a return that can never run)
don't stop the build. With --watch, gstc keeps the mistakes on screen
and waits; save your fix and it checks again by itself.
Mistakes it catches for you
Before your program runs — as a clear error, not a mystery:
- values that don't fit their type (var b: u8 = 300)
- using the wrong type anywhere (no silent conversions)
- comparing things that can't be compared, like two structs, or strings
with == (use str_eq)
- a pointer or slice that would outlive what it points at — for example
returning a pointer to a function's own local variable
While it runs — the program stops with a message saying exactly what and where, instead of carrying on with a wrong answer: - integer overflow, division by zero, out-of-range shifts - reading past the end of an array, slice, or string - running out of stack (usually a function calling itself forever) - using a null pointer
runtime error: integer overflow: the result of '+' doesn't fit in i32 (at line 4, column 11)
in total<i32>
called from middle, at line 10
called from the main program, at line 19
Programs give the same output on every platform, down to the last digit
of sin(1.0): StingType carries its own math functions instead of relying
on each system's.
Windows: frame.sth
lib/frame.sth opens a window and tells you what the user does with
it — the same program on Windows and Linux:
include "lib/frame.sth" as frame
if frame.open("My Window", 640, 480) then
while frame.is_open() do
frame.wait() // or frame.poll() in a game loop
if frame.key_pressed(frame.KEY_ESCAPE) then
frame.close()
end
if frame.mouse_pressed(frame.MOUSE_LEFT) then
print_i32(frame.mouse_x())
end
end
end
open(title, width, height),is_open(),poll()/wait(),close(),set_title(t),resize(w, h),width(),height(),was_resized(),set_fullscreen(on)/toggle_fullscreen()- text input:
typed_count()andtyped_char(i)-- the characters typed this round, with shift and the keyboard layout already applied - first-person mouse:
set_mouse_capture(true)hides the pointer and keeps it centred;mouse_delta_x()/mouse_delta_y()give the movement;mouse_clicks(b)counts presses mouse_wheel(): notches the wheel turned since last asked (up is positive; 0 where there's no wheel)key_down(k)/key_pressed(k)withframe.KEY_A…KEY_Z,KEY_0…KEY_9,KEY_ESCAPE,KEY_ENTER,KEY_SPACE, the arrows,KEY_F1…KEY_F12, Shift/Control/Alt — the same codes everywheremouse_x(),mouse_y(),mouse_down(b)/mouse_pressed(b)withframe.MOUSE_LEFT,MOUSE_RIGHT,MOUSE_MIDDLEnative_window()andnative_display(): the window as the system knows it (Windows: HWND and HINSTANCE; Linux: the X11 Window and Display*) — what graphics APIs (Vulkan, DirectX, OpenGL) take to draw into it. frame itself doesn't draw.
On Windows it uses Win32 (user32.dll), on Linux X11 (libX11.so.6).
See examples/interactive/window.st.
Building for one system: target_windows() and target_linux()
if target_windows() then ... else ... end compiles only the branch for
the system being built for — the other branch is still checked for
mistakes, but its code and its library imports are left out, so a
Windows program never needs a Linux library (or the other way round).
Shaders: write once, run on every API
A shader is a block in any StingType file, like a struct or a function. gstc compiles it — at build time, by itself — into GLSL (OpenGL), HLSL (Direct3D 11 and 12) and SPIR-V (Vulkan):
shader waves
uniforms
time: f32
tint: vec4
enduniforms
vertex(position: vec2, color: vec3) // the vertex inputs, in order
varying v_color: vec3 // handed to the fragment stage
v_color = color
return vec4(position, 0.0, 1.0) // the position
endvertex
fragment
return vec4(v_color, 1.0) * tint // the pixel's color
endfragment
endshader
The block becomes a namespace, like an include: waves.source() (for
sgi.program) and waves.Uniforms (its uniforms, laid out the way every
API agrees on):
if sgi.open(sgi.VULKAN, frame.native_display(), frame.native_window(), w, h) then
var prog: sgi.Program = sgi.program(waves.source())
var mesh: sgi.Mesh = sgi.mesh(&verts[0] as *u8, bytes, vertex_count)
var u: waves.Uniforms
u.time = 1.5
u.tint = [1.0, 0.8, 0.6, 1.0]
while frame.is_open() do
frame.poll()
sgi.resize(frame.width(), frame.height())
if sgi.begin(0.0, 0.0, 0.0, 1.0) then // start the frame, cleared
sgi.draw(&prog, &mesh, &u as *u8)
sgi.present()
end
end
end
Change sgi.VULKAN to sgi.OPENGL, sgi.D3D11 or sgi.D3D12 (or use
sgi.api_named("d3d12")) and nothing else changes.
- Types:
f32,vec2,vec3,vec4,mat4(column-major,m * vas in math),bool. Statements:var, assignment,if/elseif/else,return. Expressions:+ - * /(vectors by component, with numbers,mat4 * vec4,mat4 * mat4), comparisons,&& || !, swizzles (.xy,.rgb),vec2/vec3/vec4(...), andsin cos tan sqrt abs floor fract exp log min max pow step clamp mix smoothstep dot cross length normalize(a number stands in for a vector:mix(a, b, 0.5)). - Depth: a
depthline in the block makes its draws depth-tested — nearer things hide farther ones. Depth runs from 0 (near) to 1 (far) in every API (gstc converts it for OpenGL); MathLib'smat4_perspectivemakes exactly that. - Uniforms each take a 16-byte slot (a
mat4four). Each draw gets its own uniform values. - Shader mistakes are reported like any other, with the line highlighted.
gstc --shader file.sth NAME outwrites what shader NAME became:out.vert.glsl,out.frag.glsl,out.hlsl,out.vert.spv,out.frag.spv.- A shader block in an included library is used by its own name
(
glow.source()), whatever the library was included as. - Examples:
examples/interactive/shaders.st(moving color waves) andcube.st(3D: a spinning, lit, textured cube from an OBJ model).
3D math (MathLib)
math.Mat4 holds a 4x4 matrix as 16 f32s, ready for a shader's mat4
uniform (u.transform = m.m): mat4_identity, mat4_mul(a, b) (b
first, then a), mat4_translate, mat4_scale, mat4_rotate_x/_y/_z
(radians) and mat4_perspective(fov_y, aspect, near, far) (looking down
-Z, depth 0 at near and 1 at far).
Assets: files, pictures and models
embed("file") puts a file's bytes into the program when it's
built — as a []u8, with nothing to ship alongside. The path is
relative to the file the embed is in; a missing file is an error at
build time, with the line shown.
lib/image.sth reads PNG and BMP pictures into RGBA pixels (4 bytes
each, the top row first). PNG: every color type (gray, gray + alpha,
RGB, RGBA, palette with transparency), bit depths 1 to 16, all row
filters, and its DEFLATE compression — all written in StingType. BMP:
24- and 32-bit. (Not yet: interlaced PNGs.)
lib/mesh.sth reads OBJ models: positions, texture coordinates and
normals, faces of any number of corners (split into triangles), every
corner form (v, v/vt, v//vn, v/vt/vn, negative indices). Each
vertex comes out as 8 floats — x, y, z, u, v, nx, ny, nz — ready for
sgi.mesh.
Textures in shaders: declare them in the shader block (after the
uniforms), sample them in the fragment stage — (0, 0) is the picture's
top-left in every API:
texture picture // smooth (linear) sampling
texture tiles: nearest // crisp, blocky sampling
fragment
return sample(picture, v_uv)
endfragment
include "../lib/image.sth" as image
var img: image.Image = image.load(embed("assets/logo.png"))
var tex: sgi.Texture = sgi.texture(img.pixels, img.width, img.height)
image.free(&img) // the texture has its own copy
...
if sgi.begin(0.0, 0.0, 0.0, 1.0) then
sgi.bind_texture(0, &tex) // the shader's first texture
sgi.draw(&prog, &mesh, &u as *u8)
sgi.present()
end
Make textures before a frame starts. Example:
examples/interactive/picture.st (an embedded PNG, shown with any API).
Graphics: sgi.sth (the Standard Graphical Interface)
SGI connects a frame window to a real graphics API and hands you that
API — the same program on Windows and Linux. It isn't a drawing library
of its own; it's StingType's way into the graphics APIs:
| API | Windows | Linux | open it with |
|---|---|---|---|
| OpenGL | yes (WGL) | yes (GLX) | sgi.open_gl(frame.native_display(), frame.native_window()) |
| Vulkan | yes | yes | sgi.open_vulkan(frame.native_display(), frame.native_window(), w, h) |
| Direct3D 12 | yes | — | sgi.open_d3d12(frame.native_window(), w, h) |
| Direct3D 11 | yes | — | sgi.open_d3d11(frame.native_window()) |
sgi.present() shows each frame, whichever API is open, and
sgi.resize(frame.width(), frame.height()) — cheap enough to call every
frame — keeps the API's images the window's size. A short tour:
include "lib/frame.sth" as frame
include "lib/sgi.sth" as sgi
if frame.open("Hello, Vulkan", 640, 480) then
if sgi.open_vulkan(frame.native_display(), frame.native_window(), 640, 480) then
while frame.is_open() do
frame.poll()
if sgi.vk_begin_frame() then
sgi.vk_clear(0.1, 0.2, 0.4, 1.0) // or record your own commands
sgi.present()
end
end
end
end
- OpenGL: every function is a value (
sgi.glClear,sgi.glBegin, ...);sgi.gl_proc("glCreateShader")gives any other. - Vulkan:
open_vulkanmakes the instance, surface, device, swapchain, command buffer and synchronization; each frame isvk_begin_frame(), your commands,present(). To draw:vk_shader(SPIR-V, compiled ahead of time — e.g.glslangValidator -V),vk_pipeline,vk_buffer, then per framevk_begin_rendering(r, g, b, a),vk_bind_pipeline,vk_bind_vertex_buffer,vk_draw(Vulkan 1.3's dynamic rendering). Y points up, as in the other APIs.vk_proc(name)gives any Vulkan function. - Direct3D 12: the same shape —
d3d12_begin_frame(), commands,present(). To draw:d3d12_pipeline(HLSL compiled withsgi.CompileShader(..., "vs_5_0")),d3d12_vertex_buffer, thend3d12_bind,d3d12_bind_vertex_buffer,d3d12_draw. - Direct3D 11: the pipeline's methods (
sgi.ClearRenderTargetView,sgi.CompileShader,sgi.CreateVertexBuffer,sgi.Draw, ...), andsgi.com_method(object, n)for any COM method.
The same vertices and colors make the same picture in every API.
Examples: examples/interactive/triangle_any.st (one triangle, any API:
triangle_any vulkan), sgi_apis.st (a color-fading window, any API),
triangle.st (a spinning OpenGL triangle), triangle_d3d11.st.
Code for one system goes in if target_windows() then ... else ... end
(or target_linux()): gstc compiles only the branch for the target — and
nothing after a branch that always returns — so a Windows program never
depends on a Linux library, or the other way around.
Functions as values
Through a pointer to a struct, p.field means (*p).field.
A function can be stored, passed and returned like any value. A pointer
can be cast to a function type (p as fn(i32): i32) and back, for
functions a library hands out by address. Its type
is written fn(parameter types): result:
fn twice(x: i32): i32
return x * 2
endfn
var f: fn(i32): i32 = twice // named without calling it: its value
print_i32(f(21)) // 42
struct Button
on_click: fn(i32): i32 // in struct fields, arrays, parameters...
endstruct
A function value is a real C function pointer, so system libraries can
call StingType functions back — C's own qsort sorting with a
StingType comparison, or Windows calling a window procedure:
extern "libc.so.6" fn qsort(base: *u8, count: u64, size: u64, compare: fn(*u8, *u8): i32)
call qsort(&nums[0] as *u8, 6, 4, by_value)
A function value's parameters can be anything -- numbers, pointers,
structs, arrays, strs, slices (sort_by(people, by_age) with
by_age: fn(Person, Person): bool); a struct reaches it as a pointer to
a copy, so the callee still gets its own. Its result is a number, bool,
pointer or function. A callback handed to C keeps C's kinds (numbers,
bools, pointers), since C passes structs its own way. Functions from a namespaced library work as values too
(math.sin), and MathLib's bisect, integrate and newton take any
function.
Calling system libraries (FFI)
Any function in a Windows DLL or a Linux shared library can be declared
with extern and then called like a StingType function:
extern "user32.dll" fn MessageBoxA(window: *u8, text: *u8, title: *u8, kind: u32): i32
extern "libc.so.6" fn getenv(name: *u8): *u8
extern "libm.so.6" fn hypot(a: f64, b: f64): f64
var r: i32 = MessageBoxA(0 as *u8, str_ptr("Hello!"), str_ptr("StingType"), 0)
print_f64(hypot(3.0, 4.0))
- Parameters and results can be numbers (integers and floats),
booland pointers. Pass a struct as a pointer (&s) — StingType lays out structs exactly like C, so the library reads and writes them directly. Pass text withstr_ptr(s): string literals end with a 0 byte, sostr_ptr("...")is a ready C string. - gstc uses each system's own calling convention (Windows x64, or Linux's System V), floats and more-than-register arguments included.
- Static libraries: name a
.aarchive (or a.ofile) and gstc links its object code INTO the program, as a C linker does — no.soneeded on the system that runs it:extern "libmylib.a" fn checksum(p: *u8, n: u64): u32. Only the members that define what the program uses are pulled in (and what those need). Symbols no archive defines come from the system's C library, as in an ordinary C link; a library that needs nothing from it makes a fully static program. The path is relative to the file theexternis in. Linux for now (x86-64 ELF objects, any code model gcc makes); not yet: thread-local variables, C constructors, Windows.lib. - When the StingType name has to differ from the library's (two systems'
versions of one function), name the library's symbol with
as:extern "libvulkan.so.1" fn vk_gipa(i: *u8, n: *u8): *u8 as "vkGetInstanceProcAddr". - On Windows the DLLs go in the
.exe's import table. On Linux a program that calls a library becomes dynamically linked (the system loader finds the libraries); one that doesn't stays fully static. - If a program uses the C library (
libc.so.6,msvcrt.dll), it exits through the C library'sexit, so textprintf/putsbuffered is never lost. (That text is written when the buffer fills or the program ends, so it can appear afterprint_*output.)
MathLib (STSML)
lib/mathlib.sth is StingType's math library — the original STSML,
remade for today's StingType and written from first principles:
include "lib/mathlib.sth" as math
print_f64(math.sin(math.PI / 6.0))
var v = math.vec2_rotate(math.Vec2(x: 1.0, y: 0.0), math.PI / 2.0)
print_bool(math.is_prime(1000000007))
Constants (PI, TAU, E, PHI, SQRT2, ...), rounding and exact remainders,
exp/ln/log2/log10/pow, trigonometry in radians and degrees (exact
argument reduction for any input, sin(1e22) included), hyperbolics,
number theory, interpolation and Bézier curves, statistics on slices,
Vec2/Vec3/Mat2/Complex, numerical methods, geometry, and base
conversion. The transcendental functions are within 1 unit in the last
digit on 98-100% of inputs (pow, exp, ln, atan, cbrt are
correctly rounded 93-99% of the time), and give bit-identical results on
Linux and Windows. The built-in sin_f64, pow_f64 and friends are
MathLib's functions.
Compression: lib/deflate.sth
DEFLATE compression and decompression, the gzip format and CRC-32, all
in StingType — what gzip, PNG and zip use:
include "lib/deflate.sth" as deflate
var gz: deflate.Bytes = deflate.gzip(data, size) // a .gz file's bytes
var back: deflate.Bytes = deflate.gunzip(gz.data, gz.size)
if !back.ok then print_str(back.error) end
deflate.free(&gz)
Its output is standard (gzip reads it), and it reads what gzip writes,
checking the CRC-32 and size. examples/stzip.st is a complete
gzip-compatible command-line tool built on it:
stzip c file file.gz, stzip d file.gz file.
32-bit programs (linux32, windows32)
gstc also builds 32-bit x86 (i386) programs, for 32-bit Linux and 32-bit Windows (and 64-bit systems running 32-bit programs):
gstc hello.st -o hello --target=linux32 # an ELF32 program
gstc hello.st -o hello --target=windows32 # a PE32 .exe
The same program gives the same results: the whole language, the runtime's safety checks and error reports (with the chain of calls), 64-bit integers (done in pairs of 32-bit registers), floats (SSE2), files, arguments, and calls into C libraries. What differs is what differs in C:
- Pointers are 4 bytes, so structs containing pointers are smaller,
laid out by each system's C rules (
i64/u64/f64fields align to 4 bytes on Linux, 8 on Windows). - C's pointer-sized types (
size_t,longon Linux,WPARAM,LPARAM) are 32 bits. Declare them asusize/isizeand the sameexternserves both widths (see "Pointer-sized integers" below). - 32-bit Windows has two calling conventions for callbacks: cdecl
(C's default --
qsort,bsearch) and stdcall (what Windows calls window procedures with). Mark the second kindstdcall fn(see "Calling conventions" below). - Raw system call numbers are the platform's own, as in C: 32-bit
Linux's
writeis 4 (64-bit: 1).syscall0..syscall6useint 0x80. frame.sthandsgi.sthwork on all four targets -- every API (OpenGL and Vulkan on linux32; OpenGL, Vulkan, Direct3D 11 and 12 on windows32) draws the same pixels as its 64-bit build. Not yet on 32-bit: static libraries (.a).
32-bit programs are fixed-address executables (the code addresses its data directly), the usual choice for i386.
Pointer-sized integers: usize and isize
usize and isize are as wide as a pointer -- 64 bits in a 64-bit
program, 32 in a 32-bit one -- like C's size_t and ptrdiff_t. They
exist for talking to C, whose size_t, long (on Linux), WPARAM,
LPARAM and X11 Window change width with the pointer:
extern "libc.so.6" fn strlen(s: *u8): usize // size_t: right on 64-bit AND 32-bit
extern "libc.so.6" fn qsort(base: *u8, n: usize, size: usize, cmp: fn(*u8, *u8): i32)
var addr: usize = p as usize // an address, as an integer (C's uintptr_t)
They're integers like any other -- checked arithmetic (a 32-bit usize
overflows where a 64-bit one wouldn't, and says so), literal range checks
for the target being built -- and, like every StingType type, they never
mix with others silently: usize and u64 need an as between them,
even on 64-bit, so a program that builds for 64-bit also builds for
32-bit. (The built-ins keep their fixed types: .len, str_len,
sizeof and alloc use u64.)
Calling conventions: stdcall and cdecl
C code calls a StingType function (a function value handed to it) with
C's usual convention, cdecl. On 32-bit Windows there's a second one,
stdcall, which Windows uses for its callbacks -- window procedures, for
one. Mark those as C programs do with CALLBACK (__stdcall):
stdcall fn window_proc(hwnd: *u8, msg: u32, wparam: usize, lparam: isize): isize
cdecl fn says the default out loud; either word can also stand before
extern, for documentation (calls to C work with both conventions).
On 64-bit Windows and on Linux there is only one convention, so the
markers change nothing there -- exactly as C compilers ignore
__stdcall on them. Outside that position, stdcall and cdecl are
ordinary names.
Freestanding programs (no operating system)
Two more targets build programs that run with no operating system at all -- kernels, boot-time tools, firmware-style programs, anything that owns the machine:
gstc kernel.st --target=freestanding-x64 # 64-bit
gstc kernel.st --target=freestanding-32 # 32-bit (i386)
The result is a plain ELF with no OS calls and no boot code of any
kind: no Multiboot header, no mode switch, no page tables. The entry
point is the program's own first instruction, and the loader is
yours -- write your own bootloader, use Limine, or anything that loads
an ELF and jumps to its entry. It loads at one fixed address (1 MiB by
default, --base=ADDRESS to move it). On x64 every reference is relative
to the instruction, so --base can be anywhere, a higher-half kernel
too:
gstc kernel.st --target=freestanding-x64 --base=0xffffffff80000000
Your loader loads the segment at its address (clearing the part past the
file: the stack) and jumps in -- on x64 already in 64-bit mode with the
image mapped, on i386 in 32-bit protected mode. What it hands over: on
x64, boot_info() is what it put in rdi and boot_magic() what it put
in rsi (a C-style call entry(info, magic)); on i386, eax (magic) and
ebx (info). The program sets up its own stack (64 KiB, or
--stack=BYTES) and turns on SSE itself (in ring 0), so floats work.
tests/freestanding/run_noboot.py is a tiny loader (on an emulated CPU)
the test suite runs these programs with. (--no-boot, from older
versions, is still accepted: it's simply how every build works now.)
Nothing is automatic. As in C without a C library, the program decides where things go, by defining these functions (only the ones it needs):
| Define this | And this works |
|---|---|
fn system_write(stream: i32, bytes: *u8, count: usize) |
print_*, and runtime error reports (stream 1 is output, 2 is errors) |
fn system_alloc(size: usize): *u8 |
alloc, str_concat and everything else that allocates (blocks come back zeroed; return 0 when out of memory) |
fn system_free(block: *u8) |
dealloc gives blocks back (without it, they simply aren't) |
fn system_exit(code: i32) |
the end of the program, and runtime errors (code 1) |
Without a system_write, using print_str is a compile error saying
what to define (and a runtime error's message goes nowhere -- the program
still stops). Without a system_exit, or when it returns, the processor
stops for good (cli + hlt in ring 0). What needs an operating system
-- files, arguments, standard input, syscall0..6, calling into
libraries -- is a compile error where it's used; a library's functions
only count if the program really calls them, so including
lib/standard.sth is fine.
Reaching the hardware:
| Tool | What it does |
|---|---|
port_in8/16/32(port) |
the in instruction: read a device's I/O port |
port_out8/16/32(port, value) |
the out instruction: write one |
peek8..64 / poke8..64 |
memory-mapped hardware (VGA text memory at 0xB8000, ...) |
asm_bytes(0xFA) |
any instruction without operands (cli 0xFA, sti 0xFB, hlt 0xF4) |
boot_magic(), boot_info() |
what your loader handed over (x64: rsi and rdi; i386: eax and ebx) |
target_freestanding() |
true when building for a freestanding target (decided while compiling, like target_linux()) |
Everything else is the same language: every safety check, runtime errors
with the chain of calls (including stack overflow, on the program's own
stack), 64-bit math, floats, structs, strings. examples/freestanding/kernel.st
is a small kernel that writes to the screen and the serial port and does
some real work, for your loader to start.
Not there yet: interrupt handlers (the iret side), and linking static
libraries into a freestanding program.
64-bit ARM Linux (--target=linux-arm64)
gstc hello.st -o hello --target=linux-arm64
An AArch64 ELF program for 64-bit ARM Linux: a Raspberry Pi 3/4/5 with a
64-bit OS, ARM servers and laptops running Linux. gstc writes the ARM64
machine code itself, like everywhere else, and the whole language comes
along: every safety check and error report with the chain of calls,
floats, threads and atomics (ldxr/stxr), functions as values, C
libraries (extern, the AAPCS64 calling convention, callbacks from C),
and the libraries (frame.sth, sgi.sth's OpenGL, sound, net, files,
time). A program that calls no C library is a static executable; one
that does is dynamically linked. On an ARM machine, gstc builds this by
default (native).
target_arm64() is true while building for ARM64 (Linux or Android),
and raw system calls use ARM64 Linux's own numbers there (write is 64,
not 1), as in C.
Android apps (--target=android)
gstc game.st --target=android --assets=assets --app-name="My Game" --package=com.me.game
adb install -r game.apk
adb logcat -s StingType # what the program prints
gstc makes the whole app itself: the ARM64 code (as a library Android's
NativeActivity loads, so the app needs no Java at all), the manifest
(in Android's binary XML), the icon's resource table, the package
(the library stored uncompressed, 16 KB aligned, and unpacked by Android
at install), and the signature (APK Signature Scheme v2, RSA with SHA-256).
No Android SDK, Studio, Java or NDK is involved. Android 8.0 (API 26) and
up, on 64-bit ARM phones and tablets — practically every one sold since
2017 (Android 11 and later emulators run ARM apps too).
The same program runs on a phone. What changes:
frame.sth:open()gets the app's full-screen window (the size asked for is ignored —resize(0, 720)instead draws at 720 pixels tall and lets the screen stretch it, which keeps pixel-laid-out UIs readable on 3K screens). Touch:touch_count(),touch_x(i),touch_y(i),touch_id(i),touch_started(i); the first finger also acts as the mouse (set_touch_mouse(false)turns that off). The Back button isKEY_ESCAPE.show_keyboard(true)asks for Android's on-screen keyboard (letters arrive throughtyped_char, as on a PC); some phones' keyboards (Samsung's, for one) won't open for a NativeActivity app, so for typing that must work everywhere, draw a keyboard and feed it in withtype_char.is_paused()says the app is in the background — the window is gone then, so stop drawing (wait()sleeps until something happens).- Virtual input (every system):
press_key(key, down),press_button(button, down),type_char(c)andmove_mouse_by(dx, dy)act as the keyboard and mouse would, so on-screen controls can drive code written for a keyboard unchanged.touch_not_mouse(x, y, w, h): fingers that come down in that rectangle aren't the mouse, so pressing on-screen controls drawn over a menu doesn't click the menu under them. sgi.sth: OpenGL is OpenGL ES 3 (through EGL);open_gl()gives it. Classic OpenGL (glBegin/glEnd,GL_QUADS, the matrix stack,glColor, textures) is emulated on top of it, so the same drawing code works; shaders written forgl_stageget ES's version line by themselves. Vulkan and Direct3D aren't on Android (open_vulkansays false).open_gles(display, window)asks for GL ES directly, on Linux too.sound.sthplays through AAudio, and goes quiet while the app is in the background.- Printing goes to the system log (
adb logcat -s StingType), a line at a time; runtime errors (with the chain of calls) go there too, at priority E. When a program stops with an error -- a runtime error,halt(), a crash (a null pointer, an instruction the phone's CPU lacks, an abort) -- the error and what was printed just before it are drawn on the screen until it's tapped, since a phone has no console. A crash reports its signal and the chain of calls, like on a PC. - Files: the program starts in the app's private folder, and the
folders given with
--assetsare copied there the first time (and again whenever they change), soread_file("assets/x.png")works as on a PC. - net, threads, time, system work as on Linux (with Android's C
library where that differs: name lookups,
getenv, the shell is/system/bin/sh). target_android()is true for this target (andtarget_linux()too, since Android is Linux underneath). A C library named with a version (libX11.so.6) or a.dllis refused while building for Android — Android's libraries are plainlibname.so— with a hint to keep that call for other systems behindif !target_android() then.
| Option | What it does |
|---|---|
--app-name=NAME |
shown under the icon (default: the file's name) |
--package=com.you.app |
the app's identity (default org.stingtype.NAME); an update must keep it |
--version-code=N, --version-name=1.0 |
N must go up with each release you install over the last |
--icon=FILE.png |
the icon (default: a StingType one) |
--assets=FOLDER |
copied beside the program, keeping its name (again for more) |
--orientation=any\|landscape\|portrait |
how the screen may turn |
--permission=NAME |
another permission, e.g. VIBRATE (again for more) |
--no-internet |
leave out the INTERNET permission (it's in by default, for net.sth) |
--debuggable |
lets adb shell run-as look inside the app's folder |
--key=K.pk8 --cert=C.x509.der |
sign with this key (unencrypted RSA, PKCS#8 or PKCS#1 DER, and its X.509 certificate) |
-o NAME.so |
only the library, no .apk |
Signing. Android installs only signed apps, and an update must be
signed by the same key as what it replaces. Without --key, gstc makes
an RSA-2048 key the first time and keeps it in
~/.gstc/android/ (%USERPROFILE%\.gstc\android\ on Windows): keep
that folder, and every later build installs over the last. For the Play
Store, use a key of your own (--key/--cert; Play App Signing works
with any).
The standard library
lib/standard.sth — 50 functions, written in StingType itself:
- Input/output:
write,writeln,write_int,write_f64,read_line,read_i32,read_file,write_file,append_file,file_exists - Strings:
str_eq,str_contains,str_index_of,str_substring,str_trim,str_to_upper,str_from_int,str_parse_i32, and more - Math (any number type):
min,max,abs,clamp,sign,gcd,power - Slices (any element type):
sum,avg,min_in,max_in,contains,index_of,count_of,sort,reverse,fill,swap(sortis an introsort: n log n at worst -- a million numbers in well under a second) - Numbers from text:
parse_int<T>(decimal,0x,0b) andparse_f64, which gives the double nearest the exact decimal value (correctly rounded, however many digits)
Built into the language itself: print_i32/print_f64/print_str/...,
sqrt_f64, sin_f64, cos_f64, pow_f64, log_f64 and friends,
wrap_add/wrap_mul (arithmetic that wraps instead of stopping, for
hashes), memory (alloc, dealloc), and files.
Functions that build a new string (like str_to_upper) give you a string
you own: call str_free on it when you're done.
Project layout
src/ the compiler: lexer, parser, checker, diag.c (errors and
warnings with highlighted lines, --dbg), builtins.c (built-in
signatures), modules.c (includes), native.c (x86-64 machine
code + the ELF and PE writers), native32.c (i386),
native_a64.c (ARM64, and Android's library), apk.c (the
Android package and its signature), runtime.sth (the runtime,
in StingType), embedded.h (MathLib and the runtime, carried
inside gstc)
lib/ standard.sth and mathlib.sth (STSML)
examples/ example programs, each with its expected output
tests/ the test suites
tools/ embed_sources.py (re-embeds the .sth files into gstc)
STATUS.md the full development journal
Run every test with bash run_tests.sh (add --asan to also check for
memory errors). The examples are part of the tests: each must still print
exactly its .expected output.