ludic/runtime/native/core.ludic
Orkuncakilkaya 70ab79a4e9 Phase 7b: null literal + x == null (retire ptr_null/ptr_is_null)
`null` is now a real pointer literal and null-tests are comparisons, instead of
`ptr_null()` and `ptr_is_null(x)`:

  ptr_null()          -> null
  ptr_is_null(x)      -> (x == null)
  not ptr_is_null(x)  -> (x != null)

Mechanics: a new E_NULL primary (`null`, like true/false) lowers to the `null`
pointer; emit_bin's comparison path now picks `ptr` vs `i32` from operand type
(via llty), so `==`/`!=` work on any pointer/record/slice. The two intrinsics are
deleted.

Two reseeds: (A) add the literal + ptr comparison keeping the intrinsics; (B)
migrate all 182 call sites (compiler + runtime, via a balanced-paren script that
skips string-literal args and rewrites `not ptr_is_null` to `!= null`) and delete
the intrinsics. Node/Val/Buf/Tok field defaults now read `ptr = null`.

Vocabulary drops the two from LUDIC_INTRINSICS; `null` joins true/false as a
language constant (grammar + ludic_syntax.h). LANGUAGE.md notes the literal.
Reseeded (21664 lines); C-free fixpoint holds; goldens identical; 17/17; vocab +
doc-fences clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 00:50:21 +03:00

394 lines
10 KiB
Text

# ============================================================================
# runtime/native/core.ludic — the Ludic runtime, written in Ludic.
#
# This is what the native backend links instead of a C runtime. Everything a
# game touches through a builtin — the framebuffer, rectangles, text, the
# registers, the RNG, input, the frame dump — is implemented right here in
# Ludic and compiled to machine code by ludicc like any other Ludic source.
#
# Underneath it there is exactly one layer, and it is not C: the compiler
# intrinsics (mem_alloc, peek8/poke8, peek32/poke32, file_open/file_write,
# read_byte, …) lower to direct calls into the platform's C ABI — malloc,
# fwrite, getchar. That ABI is the operating system's interface, the same floor
# Rust and Swift stand on. No C source is compiled at any point in the build.
#
# A builtin `clear(c)` in a game resolves to `rt_clear(c)` here; that is the
# whole protocol. Swap this file and you have swapped the runtime.
#
# This file is a fragment: ludicc splices it into every native build. It is not
# a `game`/`module` block of its own.
# ============================================================================
# ---- state ----------------------------------------------------------------
var rt_fb: ptr = null # framebuffer, one i32 (0x00RRGGBB) per pixel
var rt_fbw: int = 320
var rt_fbh: int = 240
var rt_regs: ptr = null # the 64 general-purpose game registers
var rt_rng: int = 305419896 # xorshift32 state
var rt_alive: int = 1 # platform still running?
# 5x7 glyphs for ASCII 32..90, 7 rows per glyph, each row a 5-bit mask stored
# biased by '0' so the whole font is one printable string literal.
fn rt_font() -> str {
return "00000004444404000000000000000000000IJ4:FC000000000000000000000000000000E>O>E0044O4400000448000O000000004012448@@>ACEIA>4<4444>>A168@ON11>11N26:BO22O@N11A>>@@NAA>O124888>AA>AA>>AA?11>04004000000000024842000O0O000842480>A164040000000>AAOAAANAANAAN>A@@@A>LBAAABLO@@L@@OO@@L@@@>A@GAA>AAAOAAA>44444>7222BB<ABDHDBA@@@@@@OAKEEAAAAIEECAA>AAAAA>NAAN@@@>AAAEB=NAANDBA>A@>1A>O444444AAAAAA>AAAAA:4AAAEEKAAA:4:AAAA:4444O1248@O"
}
fn rt_init() -> void {
rt_fb = mem_alloc(320 * 240 * 4)
rt_regs = mem_alloc(64 * 4)
mem_set(rt_regs, 0, 64 * 4)
rt_map = mem_alloc(96 * 64)
mem_set(rt_map, 32, 96 * 64)
rt_statusbuf = mem_alloc(96)
poke8(rt_statusbuf, 0, 0)
rt_image_init()
rt_tt_init()
rt_ui_init()
rt_clear(0)
if is_windowed() {
win_open(rt_fbw, rt_fbh, 3, game_title())
}
}
fn rt_shutdown() -> void {
if is_windowed() {
win_close()
return
}
rt_dump_ppm("out.ppm")
}
# ---- framebuffer ----------------------------------------------------------
fn rt_clear(c: int) -> void {
let n = rt_fbw * rt_fbh
for i in 0 .. n {
poke32(rt_fb, i, c)
}
}
fn rt_put_px(x: int, y: int, c: int) -> void {
if x < 0 { return }
if y < 0 { return }
if x >= rt_fbw { return }
if y >= rt_fbh { return }
poke32(rt_fb, y * rt_fbw + x, c)
}
fn rt_fill_rect(x: int, y: int, w: int, h: int, c: int) -> void {
let x0 = max(0, x)
let y0 = max(0, y)
let x1 = min(rt_fbw, x + w)
let y1 = min(rt_fbh, y + h)
var j = y0
while j < y1 {
let row = j * rt_fbw
var i = x0
while i < x1 {
poke32(rt_fb, row + i, c)
i = i + 1
}
j = j + 1
}
}
fn rt_frame_rect(x: int, y: int, w: int, h: int, c: int) -> void {
rt_fill_rect(x, y, w, 1, c)
rt_fill_rect(x, y + h - 1, w, 1, c)
rt_fill_rect(x, y, 1, h, c)
rt_fill_rect(x + w - 1, y, 1, h, c)
}
# Windowed: hand the framebuffer to the platform layer, which blits it into
# the view. Headless: nothing to do until shutdown writes the last frame out.
fn rt_present() -> void {
if is_windowed() {
win_present(rt_fb, rt_fbw, rt_fbh)
}
}
# ---- text -----------------------------------------------------------------
fn rt_glyph(x: int, y: int, ch: int, colour: int, sc: int) -> void {
var c = ch
if c >= 97 {
if c <= 122 { c = c - 32 }
}
if c < 32 { return }
if c > 90 { return }
let base = (c - 32) * 7
let font = rt_font()
for row in 0 .. 7 {
let bits = peek8(font, base + row) - 48
var b = bits
for cc in 0 .. 5 {
let on = b / 16
if on == 1 {
rt_fill_rect(x + cc * sc, y + row * sc, sc, sc, colour)
}
b = (b - on * 16) * 2
}
}
}
fn rt_text(x: int, y: int, s: str, colour: int, sc: int) -> void {
var i = 0
var cx = x
var ch = peek8(s, 0)
while ch != 0 {
rt_glyph(cx, y, ch, colour, sc)
cx = cx + 6 * sc
i = i + 1
ch = peek8(s, i)
}
}
fn rt_text_int(x: int, y: int, n: int, colour: int, sc: int) -> void {
if n == 0 {
rt_glyph(x, y, 48, colour, sc)
return
}
var v = n
var cx = x
if v < 0 {
rt_glyph(cx, y, 45, colour, sc)
cx = cx + 6 * sc
v = 0 - v
}
var digits = 0
var t = v
while t > 0 {
digits = digits + 1
t = t / 10
}
var p = digits
while p > 0 {
var div = 1
for k in 1 .. p {
div = div * 10
}
rt_glyph(cx, y, 48 + (v / div) % 10, colour, sc)
cx = cx + 6 * sc
p = p - 1
}
}
# ---- registers ------------------------------------------------------------
fn rt_reg(i: int) -> int {
if i < 0 { return 0 }
if i >= 64 { return 0 }
return peek32(rt_regs, i)
}
fn rt_set_reg(i: int, v: int) -> void {
if i < 0 { return }
if i >= 64 { return }
poke32(rt_regs, i, v)
}
# ---- rng (xorshift32) -----------------------------------------------------
fn rt_seed(s: int) -> void {
if s == 0 {
rt_rng = 305419896
return
}
rt_rng = s
}
# xorshift32 (Marsaglia). Runs on the raw 32-bit pattern, so the sign bit is
# masked off only when a caller asks for a number.
fn rt_next_rand() -> int {
var x = rt_rng
x = (x ^ (x << 13))
x = (x ^ (x >> 17))
x = (x ^ (x << 5))
rt_rng = x
return (x & 2147483647)
}
fn rt_rng_range(lo: int, hi: int) -> int {
if hi <= lo { return lo }
return lo + rt_next_rand() % (hi - lo + 1)
}
fn rt_rng_chance(pct: int) -> bool {
return rt_next_rand() % 100 < pct
}
# ---- platform: input ------------------------------------------------------
fn rt_poll() -> int {
if is_windowed() {
return win_poll()
}
let c = read_byte()
if c < 0 {
rt_alive = 0
return 0
}
if c == 113 { # 'q' quits, as in the headless C platform
rt_alive = 0
}
return c
}
fn rt_running() -> bool {
if is_windowed() {
return win_running()
}
return rt_alive
}
# ---- writing the frame out ------------------------------------------------
fn rt_put_str(buf: ptr, at: int, s: str) -> int {
var i = 0
var n = at
var ch = peek8(s, 0)
while ch != 0 {
poke8(buf, n, ch)
n = n + 1
i = i + 1
ch = peek8(s, i)
}
return n
}
fn rt_put_int(buf: ptr, at: int, v: int) -> int {
if v == 0 {
poke8(buf, at, 48)
return at + 1
}
var digits = 0
var t = v
while t > 0 {
digits = digits + 1
t = t / 10
}
var n = at
var p = digits
while p > 0 {
var div = 1
for k in 1 .. p {
div = div * 10
}
poke8(buf, n, 48 + (v / div) % 10)
n = n + 1
p = p - 1
}
return n
}
fn rt_dump_ppm(path: str) -> void {
let f = file_open(path, "wb")
if (f == null) { return }
let hdr = mem_alloc(64)
var n = rt_put_str(hdr, 0, "P6\n")
n = rt_put_int(hdr, n, rt_fbw)
n = rt_put_str(hdr, n, " ")
n = rt_put_int(hdr, n, rt_fbh)
n = rt_put_str(hdr, n, "\n255\n")
file_write(f, hdr, n)
let px = rt_fbw * rt_fbh
let buf = mem_alloc(px * 3)
for i in 0 .. px {
let c = peek32(rt_fb, i)
poke8(buf, i * 3, (c / 65536) % 256)
poke8(buf, i * 3 + 1, (c / 256) % 256)
poke8(buf, i * 3 + 2, c % 256)
}
file_write(f, buf, px * 3)
file_close(f)
mem_free(buf)
mem_free(hdr)
}
import "inflate.ludic"
import "image.ludic"
import "truetype.ludic"
import "ui.ludic"
# ---- tilemap --------------------------------------------------------------
# A character grid the game paints with map_row() and reads with tile(). Stored
# as one byte per cell in a flat buffer; out-of-bounds reads answer '#' so a
# caller can treat the edge of the world as wall without special-casing it.
var rt_map: ptr = null
var rt_mapw: int = 0
var rt_maph: int = 0
fn rt_map_size(w: int, h: int) -> void {
rt_mapw = clamp(w, 0, 96)
rt_maph = clamp(h, 0, 64)
mem_set(rt_map, 32, 96 * 64)
}
fn rt_map_row(y: int, s: str) -> void {
if y < 0 { return }
if y >= 64 { return }
var x = 0
var ch = peek8(s, 0)
while ch != 0 {
if x >= 96 { return }
poke8(rt_map, y * 96 + x, ch)
x = x + 1
ch = peek8(s, x)
}
}
fn rt_tile(x: int, y: int) -> int {
if x < 0 { return 35 }
if y < 0 { return 35 }
if x >= rt_mapw { return 35 }
if y >= rt_maph { return 35 }
return peek8(rt_map, y * 96 + x)
}
# ---- status line ----------------------------------------------------------
# One persistent string of feedback/dialogue, copied into runtime-owned memory
# so it survives whatever the caller does with the original.
var rt_statusbuf: ptr = null
fn rt_status(s: str) -> void {
var i = 0
var ch = peek8(s, 0)
while ch != 0 {
if i >= 95 { ch = 0 }
if ch != 0 {
poke8(rt_statusbuf, i, ch)
i = i + 1
ch = peek8(s, i)
}
}
poke8(rt_statusbuf, i, 0)
}
fn rt_status_text() -> ptr {
return rt_statusbuf
}
# ---- snapshot: the runtime serialises its own half ------------------------
# The compiler writes the ECS (entities, components, archetype kinds) because
# only it knows their shape. Everything below belongs to the runtime, so the
# runtime writes it — same order both ways.
fn rt_save_state(f: ptr) -> void {
let w = mem_alloc(16)
poke32(w, 0, rt_rng)
poke32(w, 1, rt_mapw)
poke32(w, 2, rt_maph)
poke32(w, 3, rt_alive)
file_write(f, w, 16)
file_write(f, rt_regs, 64 * 4)
file_write(f, rt_map, 96 * 64)
file_write(f, rt_statusbuf, 96)
mem_free(w)
}
fn rt_load_state(f: ptr) -> void {
let w = mem_alloc(16)
file_read(f, w, 16)
rt_rng = peek32(w, 0)
rt_mapw = peek32(w, 1)
rt_maph = peek32(w, 2)
file_read(f, rt_regs, 64 * 4)
file_read(f, rt_map, 96 * 64)
file_read(f, rt_statusbuf, 96)
mem_free(w)
}