feat(lang): L7 memory is safe unless it says unsafe

The typed buffers are slices: words/floats/fixeds/doubles/pointers(n) make
zeroed, bounds-checked []int/[]float/... and the type names mean them. buffer(n)
is a []byte, with text_of, Fs.read_bytes/write_bytes and view(xs, start, n).
bytes(), indexing a raw pointer or bytes, free, resize, Memory.*, raw file calls,
data_of and C externs are refused outside unsafe { } / unsafe function, and a
project's own files may write unsafe only with --unsafe; the runtime and packages
are the platform. A slice passed to an extern goes as its data.

What the change found: Sync's atomics on a slice header, words(n) uninitialised,
input's fixed axes in ints, truetype's fixed outlines as ints, skin matrices
typed int, gl_shader's source table made from raw bytes. render3d gets safe
entry points (safe_api.ludic). Rendering is byte-identical; a frame costs the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 12:53:27 +03:00
parent 9259808f80
commit b0b0b62bce
70 changed files with 69189 additions and 64569 deletions

View file

@ -84,6 +84,37 @@ function ck_builtin(e: Node, name: pointer) -> pointer {
ck_walk_args(e)
return "int"
}
if (name == "view") and len(e.kids) == 3 {
let vt = ck_expr(e.kids[0])
ck_give("int", ck_expr(e.kids[1]), e.kids[1], "the start of a view")
ck_give("int", ck_expr(e.kids[2]), e.kids[2], "the length of a view")
if not ck_unknown(vt) and not is_slice_ty(vt) { ck_err("kind", e, `view takes a slice, and this is {ck_a(vt)}`) }
return vt
}
if (name == "bytes") or (name == "offset") {
ck_walk_args(e)
return "pointer"
}
if (name == "data_of") {
ck_walk_args(e)
return "pointer"
}
if (name == "words") {
ck_walk_args(e)
return "[]int"
}
if (name == "buffer") {
ck_walk_args(e)
return "[]byte"
}
if (name == "fixeds") or (name == "pointers") {
ck_walk_args(e)
return "[]" + name[0 .. len(name) - 1]
}
if (name == "floats") or (name == "doubles") {
ck_walk_args(e)
return "[]" + name[0 .. len(name) - 1]
}
if (name == "float_from_bits") {
ck_walk_args(e)
return "float"
@ -107,11 +138,20 @@ function ck_call(e: Node) -> pointer {
let b = c.a
if b.kind == E_ID and ck_local(b.s) < 0 and ck_global(b.s) == null {
# an alias (L6) is its target, labels and all, so its arguments are checked in full
if (b.s == "Memory") { ck_raw(e, `Memory.{c.s}`) }
let al = ns_alias_find(b.s, c.s)
if al >= 0 {
var tf = ck_fn(g_al_target[al])
if tf == null { tf = ck_extern(g_al_target[al]) }
if tf != null { return ck_call_alias(e, `{b.s}.{c.s}`, al, tf) }
if tf == null {
tf = ck_extern(g_al_target[al])
if tf != null { ck_raw(e, `{b.s}.{c.s}, a C function`) }
}
if tf != null {
ck_extern_arg = ck_extern(g_al_target[al]) != null
let at = ck_call_alias(e, `{b.s}.{c.s}`, al, tf)
ck_extern_arg = false
return at
}
ck_walk_args(e)
return "?"
}
@ -141,6 +181,7 @@ function ck_call_named(e: Node, name: pointer) -> pointer {
ck_walk_args(e)
return "?"
}
if ck_raw_builtin(name) { ck_raw(e, `{name}()`) }
let bt = ck_builtin(e, name)
if bt != null { return bt }
let f = ck_fn(name)
@ -148,7 +189,13 @@ function ck_call_named(e: Node, name: pointer) -> pointer {
let gt = gen_template(g_gen_fns, name)
if gt != null { return gen_call(e, name, gt) }
let x = ck_extern(name)
if x != null { return ck_call_fn(e, name, x) }
if x != null {
ck_raw(e, `the C function {name}`)
ck_extern_arg = true
let xt = ck_call_fn(e, name, x)
ck_extern_arg = false
return xt
}
let g = ck_global(name)
if g != null and is_fn_type(g.ty) { return ck_call_sig(e, name, g.ty) }
ck_walk_args(e)

View file

@ -53,5 +53,13 @@ function ck_err(cat: pointer, n: Node, msg: pointer) -> void {
function ck_give(to: pointer, from: pointer, e: Node, what: pointer) -> void {
let cat = ck_mismatch(to, from, e)
if cat == null { return }
if (cat == "slice-pointer") {
ck_err(cat, e, `{what} wants a pointer and this is {ck_a(from)}: take the slice itself, or data_of(xs) in unsafe code`)
return
}
if (cat == "pointer-slice") {
ck_err(cat, e, `{what} wants {ck_a(to)} and this is a pointer: a slice is made with words(n), floats(n) or new, not from an address`)
return
}
ck_err(cat, e, `{what} wants {ck_a(to)} and this is {ck_a(from)}`)
}

View file

@ -24,7 +24,7 @@ function ck_any(n: Node) -> void {
if n == null { return }
if n.kind == N_BLOCK { ck_block(n); return }
if n.kind == E_FINIT { ck_expr(n.a); return }
if (n.kind >= S_LET and n.kind <= S_BECOME) or n.kind == S_EMIT { ck_stmt(n); return }
if (n.kind >= S_LET and n.kind <= S_BECOME) or n.kind == S_EMIT or n.kind == S_UNSAFE { ck_stmt(n); return }
ck_expr(n)
}
function ck_expr(e: Node) -> pointer {
@ -125,6 +125,7 @@ function ck_member(e: Node) -> pointer {
function ck_index_of(e: Node) -> pointer {
let bt = ck_expr(e.a)
let it = ck_expr(e.b)
if ck_is_raw(bt) { ck_raw(e, `indexing {ck_a(bt)}`) }
if not ck_unknown(it) and not ck_is_int(it) { ck_err("index", e.b, `an index wants an int and this is {ck_a(it)}`) }
if ck_unknown(bt) { return "?" }
if is_slice_ty(bt) { return slice_elem(bt) }

View file

@ -107,6 +107,13 @@ function ck_stmt(s: Node) -> void {
if k == S_EXPR { ck_expr(s.a); return }
if k == S_MATCH { ck_match(s); return }
if k == S_EMIT { ck_emit(s); return }
if k == S_UNSAFE {
ck_unsafe_here(s)
ck_unsafe += 1
ck_block(s.a)
ck_unsafe -= 1
return
}
}
function ck_fn_body(d: Node) -> void {
let m = ck_mark()
@ -117,7 +124,12 @@ function ck_fn_body(d: Node) -> void {
}
ck_ret = d.ty
if ck_ret == null { ck_ret = "void" }
if d.uns == 1 {
ck_unsafe_here(d)
ck_unsafe += 1
}
ck_block(d.a)
if d.uns == 1 { ck_unsafe -= 1 }
ck_ret = "void"
ck_pop(m)
}
@ -140,7 +152,7 @@ function check_program() -> void {
gen_collect()
ck_index()
var i = 0
while i < g_prog_user_end {
while i < len(prog) {
let d = prog[i]
if d.kind == N_FN { ck_fn_body(d) }
if d.kind == N_MAIN or d.kind == N_TEST { ck_block(d.a) }

View file

@ -3,6 +3,7 @@
# agrees with everything, so the checker only ever reports a mix-up it can prove. "null" is the
# type of the null literal, and `pointer` is untyped: numbers, bools, text, records, slices and
# functions are kept apart, and a pointer is trusted to be whatever it is given as.
var ck_extern_arg: bool = false # an extern's arguments are being given: a slice goes as its data
function ck_unknown(t: pointer) -> bool { return t == null or (t == "?") }
function ck_is_int(t: pointer) -> bool {
if (t == "int") or (t == "long") or (t == "byte") or (t == "i64") or (t == "u8") { return true }
@ -72,6 +73,17 @@ function ck_mismatch(to: pointer, from: pointer, e: Node) -> pointer {
if ck_is_num(from) { return "kind" }
# `pointer` is the untyped reference, C's void *: it goes anywhere a reference does, and a
# reference goes into it. What it may hold is L7's question (unsafe), not this pass's.
# `bytes` is a raw buffer, a pointer by another name: the same rules
if (to == "bytes") and is_slice_ty(from) {
if ck_extern_arg { return null }
return "slice-pointer"
}
if (from == "bytes") and is_slice_ty(to) { return "pointer-slice" }
if (to == "pointer") and is_slice_ty(from) {
if ck_extern_arg { return null }
return "slice-pointer"
}
if (from == "pointer") and is_slice_ty(to) { return "pointer-slice" }
if (to == "pointer") or (from == "pointer") { return null }
if (to == "string") { return "kind" }
if ck_is_rec(to) and ck_is_rec(from) { return "record" }

View file

@ -0,0 +1,29 @@
# check_unsafe.ludic — L7: raw memory is `unsafe`. A bytes() buffer, indexing a bare pointer,
# data_of(a slice), free, resize, the raw file calls, Memory.* and calling a C function (an
# `extern`) are refused outside an `unsafe { }` block or an `unsafe function`. The typed buffers -
# words(n), floats(n) - are slices, bounds-checked, and need none of it.
# And `unsafe` itself is only for the files that are the platform: the runtime, a package the
# toolchain or ludic_modules provides, and what they import beside them - a project's own files
# may write it only when the build says --unsafe. A game is written against APIs, not memory.
var ck_unsafe: int = 0 # how many unsafe blocks and functions enclose this point
# words(n), floats(n) and the rest are slices now - bounds-checked, safe - so what is raw is a
# bare pointer indexed, and the builtins that hand out or take back addresses
function ck_is_raw(t: pointer) -> bool { return (t == "pointer") or (t == "bytes") }
function ck_raw_builtin(name: pointer) -> bool {
if (name == "bytes") or (name == "data_of") or (name == "free") or (name == "resize") { return true }
return (name == "file_read") or (name == "file_write")
}
# the platform - the runtime and the packages - is raw memory by trade: its files are unsafe
# throughout, and the rule is for a project's own code
function ck_raw(n: Node, what: pointer) -> void {
if ck_unsafe > 0 { return }
if n != null and n.file != null and unsafe_trusted(n.file) { return }
ck_err("unsafe", n, `{what} is raw memory: it belongs inside unsafe {{ }}, and a game reaches it through an API`)
}
# an unsafe block or function where the file may not have one
function ck_unsafe_here(n: Node) -> void {
if n == null or n.file == null { return }
if unsafe_trusted(n.file) { return }
ck_err("unsafe-block", n, "unsafe is for the runtime and packages; this file may use it only when the build says --unsafe")
}