# ============================================================================ # 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: words = null # framebuffer, one i32 (0x00RRGGBB) per pixel var rt_fbw: int = 320 var rt_fbh: int = 240 var rt_regs: words = null # the 64 general-purpose game registers var rt_rng: int = 305419896 # xorshift32 state var rt_alive: int = 1 # platform still running? # ---- renderer state (camera / clip / blend) ------------------------------- # A world-space camera offset, a clip rectangle, and a blend mode threaded # through the two framebuffer chokepoints (rt_put_px / rt_fill_rect); every # draw primitive funnels through one of those, so they all inherit the state. # The defaults are neutral — camera (0,0), clip = full screen, blend = replace — # so a game that never touches them renders exactly as before. The camera moves # everything drawn; reset it to (0,0) to draw a fixed HUD over the world. var rt_cam_x: int = 0 # camera base offset (world -> screen: subtracted) var rt_cam_y: int = 0 var rt_shake_x: int = 0 # transient screen-shake offset, added to the base var rt_shake_y: int = 0 var rt_clip_x0: int = 0 # clip rectangle in screen space (half-open) var rt_clip_y0: int = 0 var rt_clip_x1: int = 320 var rt_clip_y1: int = 240 var rt_blend: int = 0 # 0 = replace, 1 = additive # 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. function rt_font() -> string { 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>7222BBAAAAA>NAAN@@@>AAAEB=NAANDBA>A@>1A>O444444AAAAAA>AAAAA:4AAAEEKAAA:4:AAAA:4444O1248@O" } function rt_init() -> void { rt_fb = words(320 * 240) rt_regs = words(64) fill(rt_regs, 0, 64 * 4) rt_map = bytes(96 * 64) fill(rt_map, 32, 96 * 64) rt_clip_x1 = rt_fbw rt_clip_y1 = rt_fbh rt_statusbuf = bytes(96) 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()) } } function rt_shutdown() -> void { if is_windowed() { win_close() return } rt_dump_ppm("build/out.ppm") } # ---- framebuffer ---------------------------------------------------------- function rt_screen_w() -> int { return rt_fbw } function rt_screen_h() -> int { return rt_fbh } function rt_clear(c: int) -> void { let n = rt_fbw * rt_fbh for i in 0 .. n { rt_fb[i] = c } } # additive blend of src over dst, per channel, clamped to 255. function rt_blend_add(dst: int, src: int) -> int { let r = min(255, ((dst >> 16) & 255) + ((src >> 16) & 255)) let g = min(255, ((dst >> 8) & 255) + ((src >> 8) & 255)) let b = min(255, (dst & 255) + (src & 255)) return (r << 16) | (g << 8) | b } # the low-level plot: apply the camera (+ shake) offset, reject anything outside # the clip rectangle or the framebuffer, then write or additively blend. function rt_put_px(x: int, y: int, c: int) -> void { let sx = x - rt_cam_x - rt_shake_x let sy = y - rt_cam_y - rt_shake_y if sx < rt_clip_x0 { return } if sy < rt_clip_y0 { return } if sx >= rt_clip_x1 { return } if sy >= rt_clip_y1 { return } if sx < 0 { return } if sy < 0 { return } if sx >= rt_fbw { return } if sy >= rt_fbh { return } let idx = sy * rt_fbw + sx if rt_blend == 1 { rt_fb[idx] = rt_blend_add(rt_fb[idx], c) } else { rt_fb[idx] = c } } function rt_fill_rect(x: int, y: int, w: int, h: int, c: int) -> void { let ox = x - rt_cam_x - rt_shake_x let oy = y - rt_cam_y - rt_shake_y let x0 = max(max(0, rt_clip_x0), ox) let y0 = max(max(0, rt_clip_y0), oy) let x1 = min(min(rt_fbw, rt_clip_x1), ox + w) let y1 = min(min(rt_fbh, rt_clip_y1), oy + h) var j = y0 while j < y1 { let row = j * rt_fbw var i = x0 while i < x1 { if rt_blend == 1 { rt_fb[row + i] = rt_blend_add(rt_fb[row + i], c) } else { rt_fb[row + i] = c } i = i + 1 } j = j + 1 } } function 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) } # A straight line by Bresenham's algorithm — integer only, any direction. function rt_line(x0: int, y0: int, x1: int, y1: int, c: int) -> void { var x = x0; var y = y0 let dx = abs(x1 - x0); let dy = 0 - abs(y1 - y0) var sx = 0 - 1; if x0 < x1 { sx = 1 } var sy = 0 - 1; if y0 < y1 { sy = 1 } var err = dx + dy while true { rt_put_px(x, y, c) if (x == x1) and (y == y1) { return } let e2 = 2 * err if e2 >= dy { err = err + dy; x = x + sx } if e2 <= dx { err = err + dx; y = y + sy } } } # A circle outline by the midpoint algorithm (eight-way symmetry). function rt_circle(cx: int, cy: int, r: int, c: int) -> void { if r < 0 { return } var x = r; var y = 0; var err = 1 - r while x >= y { rt_put_px(cx + x, cy + y, c); rt_put_px(cx + y, cy + x, c) rt_put_px(cx - y, cy + x, c); rt_put_px(cx - x, cy + y, c) rt_put_px(cx - x, cy - y, c); rt_put_px(cx - y, cy - x, c) rt_put_px(cx + y, cy - x, c); rt_put_px(cx + x, cy - y, c) y = y + 1 if err < 0 { err = err + 2 * y + 1 } else { x = x - 1; err = err + 2 * (y - x) + 1 } } } # A filled disc — one horizontal span per row, width from the circle equation. function rt_fill_circle(cx: int, cy: int, r: int, c: int) -> void { if r < 0 { return } let r2 = r * r var dy = 0 - r while dy <= r { var dx = 0 while (dx + 1) * (dx + 1) + dy * dy <= r2 { dx = dx + 1 } rt_fill_rect(cx - dx, cy + dy, 2 * dx + 1, 1, c) dy = dy + 1 } } # A triangle outline — three lines. function rt_triangle(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, c: int) -> void { rt_line(x0, y0, x1, y1, c) rt_line(x1, y1, x2, y2, c) rt_line(x2, y2, x0, y0, c) } # A filled triangle — bounding-box scan with an edge-sign inside test. function rt_fill_triangle(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, c: int) -> void { let minx = min(x0, min(x1, x2)); let maxx = max(x0, max(x1, x2)) let miny = min(y0, min(y1, y2)); let maxy = max(y0, max(y1, y2)) var py = miny while py <= maxy { var px = minx while px <= maxx { let d0 = (x1 - x0) * (py - y0) - (y1 - y0) * (px - x0) let d1 = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) let d2 = (x0 - x2) * (py - y2) - (y0 - y2) * (px - x2) let neg = (d0 < 0) or (d1 < 0) or (d2 < 0) let pos = (d0 > 0) or (d1 > 0) or (d2 > 0) if not (neg and pos) { rt_put_px(px, py, c) } px = px + 1 } py = py + 1 } } # the four-way symmetric points of an ellipse centred at (cx, cy). function rt_oval_pts(cx: int, cy: int, x: int, y: int, c: int) -> void { rt_put_px(cx + x, cy + y, c) rt_put_px(cx - x, cy + y, c) rt_put_px(cx + x, cy - y, c) rt_put_px(cx - x, cy - y, c) } # An axis-aligned ellipse outline by the midpoint algorithm — integer only, # radii rx (horizontal) and ry (vertical). rx == ry draws a circle. function rt_oval(x: int, y: int, rx: int, ry: int, c: int) -> void { if rx <= 0 { return } if ry <= 0 { return } let rx2 = rx * rx let ry2 = ry * ry let two_rx2 = 2 * rx2 let two_ry2 = 2 * ry2 var ex = 0 var ey = ry var px = 0 var py = two_rx2 * ey rt_oval_pts(x, y, ex, ey, c) var p = ry2 - rx2 * ry + rx2 / 4 # region 1 while px < py { ex = ex + 1 px = px + two_ry2 if p < 0 { p = p + ry2 + px } else { ey = ey - 1; py = py - two_rx2; p = p + ry2 + px - py } rt_oval_pts(x, y, ex, ey, c) } p = ry2 * (ex * 2 + 1) * (ex * 2 + 1) / 4 + rx2 * (ey - 1) * (ey - 1) - rx2 * ry2 # region 2 while ey > 0 { ey = ey - 1 py = py - two_rx2 if p > 0 { p = p + rx2 - py } else { ex = ex + 1; px = px + two_ry2; p = p + rx2 - py + px } rt_oval_pts(x, y, ex, ey, c) } } # Read a framebuffer pixel in screen space (0x00RRGGBB), or 0 if out of bounds. # Unlike the plot path this ignores the camera — it reads the actual screen. function rt_get_px(x: int, y: int) -> int { if x < 0 { return 0 } if y < 0 { return 0 } if x >= rt_fbw { return 0 } if y >= rt_fbh { return 0 } return rt_fb[y * rt_fbw + x] } # Advance width (pixels) of `text` in the built-in 5x7 font at scale 1: 6 per # glyph (5 wide + 1 gap), matching rt_text's cursor step. function rt_measure_text(text: string) -> int { var i = 0 while text[i] != 0 { i = i + 1 } return i * 6 } # ---- camera / clip / blend controls --------------------------------------- # Set the world-space camera offset (a world point (wx,wy) draws at # (wx-x, wy-y)). Reset to (0,0) to draw a fixed HUD. function rt_camera(x: int, y: int) -> void { rt_cam_x = x; rt_cam_y = y } # Ease the camera so (x,y) drifts toward the screen centre by `lerp` (a fixed in # 0..1): 0 keeps it still, 65536 (1.0) snaps it centred. Deterministic. function rt_camera_follow(x: int, y: int, lerp: fixed) -> void { let tx = x - rt_fbw / 2 let ty = y - rt_fbh / 2 let sx = fixed(tx - rt_cam_x) * lerp # fixed * fixed, then floor to whole pixels let sy = fixed(ty - rt_cam_y) * lerp rt_cam_x = rt_cam_x + floor(sx) rt_cam_y = rt_cam_y + floor(sy) } # Add a random screen shake of up to +/- amount pixels, drawn from the seeded # RNG (so a replay shakes identically). Call each frame with a decaying amount; # amount <= 0 clears it. function rt_camera_shake(amount: int) -> void { if amount <= 0 { rt_shake_x = 0; rt_shake_y = 0; return } rt_shake_x = rt_rng_range(0 - amount, amount) rt_shake_y = rt_rng_range(0 - amount, amount) } # Restrict drawing to a screen-space rectangle (x, y, width, height). function rt_clip(x: int, y: int, width: int, height: int) -> void { rt_clip_x0 = x rt_clip_y0 = y rt_clip_x1 = x + width rt_clip_y1 = y + height } # Reset the clip rectangle to the whole framebuffer. function rt_clip_reset() -> void { rt_clip_x0 = 0 rt_clip_y0 = 0 rt_clip_x1 = rt_fbw rt_clip_y1 = rt_fbh } # Select the pixel blend mode: 0 = replace (default), 1 = additive. function rt_blend_mode(mode: int) -> void { rt_blend = mode } # 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. function rt_present() -> void { if is_windowed() { win_present(rt_fb, rt_fbw, rt_fbh) } } # ---- text ----------------------------------------------------------------- function 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 = 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 } } } function rt_text(x: int, y: int, s: string, colour: int, sc: int) -> void { var i = 0 var cx = x var ch = s[0] while ch != 0 { rt_glyph(cx, y, ch, colour, sc) cx = cx + 6 * sc i = i + 1 ch = s[i] } } function 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 ------------------------------------------------------------ function rt_reg(i: int) -> int { if i < 0 { return 0 } if i >= 64 { return 0 } return rt_regs[i] } function rt_set_reg(i: int, v: int) -> void { if i < 0 { return } if i >= 64 { return } rt_regs[i] = v } # ---- rng (xorshift32) ----------------------------------------------------- function 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. function 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) } function rt_rng_range(lo: int, hi: int) -> int { if hi <= lo { return lo } return lo + rt_next_rand() % (hi - lo + 1) } function rt_rng_chance(pct: int) -> bool { return rt_next_rand() % 100 < pct } # a deterministic fixed-point value in [0, 1) — the raw 0..65535 is exactly the # Q16.16 fraction (fixed and int share the i32 representation). function rt_rng_value() -> fixed { return rt_rng_range(0, 65535) } # a deterministic integer in [0, max) — 0 when max <= 0 function rt_rng_int(max: int) -> int { if max <= 0 { return 0 } return rt_rng_range(0, max - 1) } # a deterministic +1 or -1 function rt_rng_sign() -> int { if rt_rng_chance(50) { return 1 } return 0 - 1 } # ---- platform: input ------------------------------------------------------ function rt_poll() -> int { if is_windowed() { return win_poll() } let c = read_char() if c < 0 { rt_alive = 0 return 0 } if c == 113 { # 'q' quits, as in the headless C platform rt_alive = 0 } return c } function rt_running() -> bool { if is_windowed() { return win_running() } return rt_alive } # ---- writing the frame out ------------------------------------------------ function rt_put_str(buf: pointer, at: int, s: string) -> int { var i = 0 var n = at var ch = s[0] while ch != 0 { buf[n] = ch n = n + 1 i = i + 1 ch = s[i] } return n } function rt_put_int(buf: pointer, at: int, v: int) -> int { if v == 0 { 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 } buf[n] = 48 + (v / div) % 10 n = n + 1 p = p - 1 } return n } function rt_dump_ppm(path: string) -> void { let f = file_open(path, "wb") if (f == null) { return } let hdr = bytes(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 = bytes(px * 3) for i in 0 .. px { let c = rt_fb[i] buf[i * 3] = (c / 65536) % 256 buf[i * 3 + 1] = (c / 256) % 256 buf[i * 3 + 2] = c % 256 } file_write(f, buf, px * 3) file_close(f) free(buf) free(hdr) } import "inflate.ludic" import "image.ludic" import "truetype.ludic" import "ui.ludic" import "grid.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: pointer = null var rt_mapw: int = 0 var rt_maph: int = 0 function rt_map_size(w: int, h: int) -> void { rt_mapw = clamp(w, 0, 96) rt_maph = clamp(h, 0, 64) fill(rt_map, 32, 96 * 64) } function rt_map_row(y: int, s: string) -> void { if y < 0 { return } if y >= 64 { return } var x = 0 var ch = s[0] while ch != 0 { if x >= 96 { return } rt_map[y * 96 + x] = ch x = x + 1 ch = s[x] } } function 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 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: pointer = null function rt_status(s: string) -> void { var i = 0 var ch = s[0] while ch != 0 { if i >= 95 { ch = 0 } if ch != 0 { rt_statusbuf[i] = ch i = i + 1 ch = s[i] } } rt_statusbuf[i] = 0 } function rt_status_text() -> pointer { 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. function rt_save_state(f: pointer) -> void { let w: words = bytes(16) w[0] = rt_rng w[1] = rt_mapw w[2] = rt_maph 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) free(w) } function rt_load_state(f: pointer) -> void { let w: words = bytes(16) file_read(f, w, 16) rt_rng = w[0] rt_mapw = w[1] rt_maph = w[2] file_read(f, rt_regs, 64 * 4) file_read(f, rt_map, 96 * 64) file_read(f, rt_statusbuf, 96) free(w) }