ludic/runtime/web/wasm.ll
Orkuncakilkaya 985f9ad8f2 Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete
Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling,
and docs. Phase 1 of the syntax-redesign cohesion pass has landed:
edge-system fix, signature-query, when-alias, and the documentation truth-pass.
Suite green (14/14), C-free bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 15:15:35 +03:00

369 lines
12 KiB
LLVM

; ============================================================================
; runtime/web/wasm.ll — the web platform layer, written in LLVM IR.
;
; This is to the browser what runtime/native/cocoa.ll is to macOS: the one
; per-platform file, hand-written in the same IR ludicc emits, assembled by the
; same toolchain and handed to the same linker. No C is compiled here either.
;
; It exists because wasm32-unknown-unknown has no libc. The Ludic runtime
; (runtime/native/*.ludic) stands on a floor of ~15 platform calls — malloc,
; memcpy, fopen, getchar — and on a native target those are the OS's. On the
; web there is no OS underneath, so this file *is* the floor:
;
; * the allocator is implemented here, over wasm linear memory;
; * memcpy/memset/strlen are implemented here;
; * everything genuinely external — files, stdin, stdout, the clock, and the
; five win_* window functions — is a wasm import, resolved by
; runtime/web/platform.js.
;
; Nothing above this file changes for the web. core.ludic, image.ludic,
; truetype.ludic and ui.ludic compile to wasm unmodified.
; ============================================================================
target triple = "wasm32-unknown-unknown"
declare i32 @llvm.wasm.memory.size.i32(i32)
declare i32 @llvm.wasm.memory.grow.i32(i32, i32)
declare void @llvm.memcpy.p0.p0.i32(ptr, ptr, i32, i1)
declare void @llvm.memmove.p0.p0.i32(ptr, ptr, i32, i1)
declare void @llvm.memset.p0.i32(ptr, i8, i32, i1)
; wasm-ld places this symbol immediately after the static data segments; its
; *address* is where our heap may begin.
@__heap_base = external global i8
; ---------------------------------------------------------------------------
; The allocator.
;
; A first-fit free list over linear memory, growing the wasm memory with
; memory.grow when it runs out. Every block carries an 8-byte header
; [i32 payload_size][i32 next_free] and the pointer handed out is header+8, so
; free() is O(1) and malloc() reuses blocks of a size it has seen before —
; which is the shape of the load the PNG decoder actually makes (a dozen tiles
; of identical dimensions, each decode allocating and releasing the same three
; buffers). Blocks are split when the fit is loose; adjacent free blocks are
; not coalesced, which costs some fragmentation and buys a lot of simplicity.
; ---------------------------------------------------------------------------
@ludic_brk = internal global i32 0 ; bump pointer; 0 until first use
@ludic_free = internal global i32 0 ; head of the free list, 0 = empty
define ptr @malloc(i32 %n0) {
entry:
%small = icmp slt i32 %n0, 1
%n1 = select i1 %small, i32 1, i32 %n0
%pad = add i32 %n1, 7
%n = and i32 %pad, -8 ; 8-byte aligned payloads
%head = load i32, ptr @ludic_free
br label %scan
scan: ; walk the free list, first fit
%cur = phi i32 [ %head, %entry ], [ %nxt, %scan.next ]
%prev = phi i32 [ 0, %entry ], [ %cur, %scan.next ]
%end = icmp eq i32 %cur, 0
br i1 %end, label %bump, label %check
check:
%cp = inttoptr i32 %cur to ptr
%sz = load i32, ptr %cp
%nxtp = getelementptr i8, ptr %cp, i32 4
%nxt = load i32, ptr %nxtp
%fits = icmp sge i32 %sz, %n
br i1 %fits, label %take, label %scan.next
scan.next:
br label %scan
take: ; unlink %cur from the free list
%first = icmp eq i32 %prev, 0
br i1 %first, label %unlink.head, label %unlink.mid
unlink.head:
store i32 %nxt, ptr @ludic_free
br label %split
unlink.mid:
%pp = inttoptr i32 %prev to ptr
%pnp = getelementptr i8, ptr %pp, i32 4
store i32 %nxt, ptr %pnp
br label %split
split: ; give the tail back if it is worth it
%floor = add i32 %n, 40
%loose = icmp sge i32 %sz, %floor
br i1 %loose, label %do.split, label %take.done
do.split:
store i32 %n, ptr %cp
%tail = sub i32 %sz, %n
%tailsz = sub i32 %tail, 8
%tailat0 = add i32 %cur, 8
%tailat = add i32 %tailat0, %n
%tp = inttoptr i32 %tailat to ptr
store i32 %tailsz, ptr %tp
%tnp = getelementptr i8, ptr %tp, i32 4
%fh = load i32, ptr @ludic_free
store i32 %fh, ptr %tnp
store i32 %tailat, ptr @ludic_free
br label %take.done
take.done:
%reuse = getelementptr i8, ptr %cp, i32 8
ret ptr %reuse
bump: ; nothing reusable: take fresh memory
%b0 = load i32, ptr @ludic_brk
%new = icmp eq i32 %b0, 0
br i1 %new, label %brk.init, label %brk.have
brk.init:
%hb = ptrtoint ptr @__heap_base to i32
%hb7 = add i32 %hb, 7
%hba = and i32 %hb7, -8
br label %brk.have
brk.have:
%brk = phi i32 [ %b0, %bump ], [ %hba, %brk.init ]
%hdr = add i32 %brk, 8
%need = add i32 %hdr, %n
%pages = call i32 @llvm.wasm.memory.size.i32(i32 0)
%have = shl i32 %pages, 16
%short = icmp ugt i32 %need, %have
br i1 %short, label %grow, label %carve
grow:
%deficit = sub i32 %need, %have
%round = add i32 %deficit, 65535
%want = lshr i32 %round, 16
%got = call i32 @llvm.wasm.memory.grow.i32(i32 0, i32 %want)
%oom = icmp eq i32 %got, -1
br i1 %oom, label %fail, label %carve
fail:
ret ptr null
carve:
%hp = inttoptr i32 %brk to ptr
store i32 %n, ptr %hp
store i32 %need, ptr @ludic_brk
%up = inttoptr i32 %hdr to ptr
ret ptr %up
}
define void @free(ptr %p) {
entry:
%nil = icmp eq ptr %p, null
br i1 %nil, label %out, label %push
push:
%pi = ptrtoint ptr %p to i32
%hi = sub i32 %pi, 8
%hp = inttoptr i32 %hi to ptr
%np = getelementptr i8, ptr %hp, i32 4
%fh = load i32, ptr @ludic_free
store i32 %fh, ptr %np
store i32 %hi, ptr @ludic_free
br label %out
out:
ret void
}
; calloc and realloc are here because LLVM *invents* calls to them. It
; recognises the allocator by name, so an -O2 pass rewrites the
; mem_alloc()+mem_set(0) that core.ludic's rt_init does into a single calloc.
; The platform layer therefore has to be a complete enough libc for whatever the
; optimiser lowers to, not just for what ludicc emits.
define ptr @calloc(i32 %count, i32 %size) {
entry:
%n = mul i32 %count, %size
%p = call ptr @malloc(i32 %n)
%nil = icmp eq ptr %p, null
br i1 %nil, label %out, label %zero
zero:
call void @llvm.memset.p0.i32(ptr %p, i8 0, i32 %n, i1 false)
br label %out
out:
ret ptr %p
}
; the block header carries its own size, so realloc can grow in place
define ptr @realloc(ptr %p, i32 %n) {
entry:
%nil = icmp eq ptr %p, null
br i1 %nil, label %fresh, label %known
fresh:
%f = call ptr @malloc(i32 %n)
ret ptr %f
known:
%pi = ptrtoint ptr %p to i32
%hi = sub i32 %pi, 8
%hp = inttoptr i32 %hi to ptr
%old = load i32, ptr %hp
%big = icmp sge i32 %old, %n
br i1 %big, label %keep, label %move
keep:
ret ptr %p
move:
%new = call ptr @malloc(i32 %n)
%bad = icmp eq ptr %new, null
br i1 %bad, label %fail, label %shift
fail:
ret ptr null
shift:
call void @llvm.memcpy.p0.p0.i32(ptr %new, ptr %p, i32 %old, i1 false)
call void @free(ptr %p)
ret ptr %new
}
; ---------------------------------------------------------------------------
; Bulk memory and strings.
;
; size_t is 32-bit on wasm32, which is the signature ludicc emits for this
; target and the one LLVM canonicalises these names to. They use the wasm
; bulk-memory instructions (memory.copy / memory.fill) rather than a byte loop.
; ---------------------------------------------------------------------------
define ptr @memcpy(ptr %d, ptr %s, i32 %n) {
call void @llvm.memcpy.p0.p0.i32(ptr %d, ptr %s, i32 %n, i1 false)
ret ptr %d
}
define ptr @memset(ptr %d, i32 %c, i32 %n) {
%b = trunc i32 %c to i8
call void @llvm.memset.p0.i32(ptr %d, i8 %b, i32 %n, i1 false)
ret ptr %d
}
define ptr @memmove(ptr %d, ptr %s, i32 %n) {
call void @llvm.memmove.p0.p0.i32(ptr %d, ptr %s, i32 %n, i1 false)
ret ptr %d
}
define i32 @memcmp(ptr %a, ptr %b, i32 %n) {
entry:
br label %loop
loop:
%i = phi i32 [ 0, %entry ], [ %i1, %step ]
%done = icmp uge i32 %i, %n
br i1 %done, label %same, label %test
test:
%ap = getelementptr i8, ptr %a, i32 %i
%bp = getelementptr i8, ptr %b, i32 %i
%av = load i8, ptr %ap
%bv = load i8, ptr %bp
%eq = icmp eq i8 %av, %bv
br i1 %eq, label %step, label %differ
step:
%i1 = add i32 %i, 1
br label %loop
differ:
%az = zext i8 %av to i32
%bz = zext i8 %bv to i32
%r = sub i32 %az, %bz
ret i32 %r
same:
ret i32 0
}
; LLVM emits bcmp for comparisons whose result is only tested against zero.
define i32 @bcmp(ptr %a, ptr %b, i32 %n) {
%r = call i32 @memcmp(ptr %a, ptr %b, i32 %n)
ret i32 %r
}
define i32 @strlen(ptr %s) {
entry:
br label %loop
loop:
%i = phi i32 [ 0, %entry ], [ %i1, %step ]
%cp = getelementptr i8, ptr %s, i32 %i
%c = load i8, ptr %cp
%z = icmp eq i8 %c, 0
br i1 %z, label %out, label %step
step:
%i1 = add i32 %i, 1
br label %loop
out:
ret i32 %i
}
; ---------------------------------------------------------------------------
; Everything genuinely outside the module.
;
; These are wasm imports: platform.js supplies them, exactly as Cocoa supplies
; objc_msgSend to cocoa.ll. Files are served from a preloaded read-only image of
; the game's assets; the save file is backed by localStorage.
; ---------------------------------------------------------------------------
declare i32 @web_fopen(ptr, ptr) ; -> handle, 0 on failure
declare i32 @web_fread(i32, ptr, i32)
declare i32 @web_fwrite(i32, ptr, i32)
declare void @web_fclose(i32)
declare i32 @web_fseek(i32, i32, i32)
declare i32 @web_ftell(i32)
declare i32 @web_time()
declare void @web_exit(i32)
; A file handle is an opaque `ptr` to the Ludic runtime, and a small integer to
; JS. Handle 0 doubles as libc's NULL, so `file_open` failure checks work
; unchanged.
define ptr @fopen(ptr %path, ptr %mode) {
%h = call i32 @web_fopen(ptr %path, ptr %mode)
%p = inttoptr i32 %h to ptr
ret ptr %p
}
define i32 @fread(ptr %buf, i32 %size, i32 %count, ptr %f) {
%bytes = mul i32 %size, %count
%h = ptrtoint ptr %f to i32
%got = call i32 @web_fread(i32 %h, ptr %buf, i32 %bytes)
ret i32 %got
}
define i32 @fwrite(ptr %buf, i32 %size, i32 %count, ptr %f) {
%bytes = mul i32 %size, %count
%h = ptrtoint ptr %f to i32
%put = call i32 @web_fwrite(i32 %h, ptr %buf, i32 %bytes)
ret i32 %put
}
define i32 @fclose(ptr %f) {
%h = ptrtoint ptr %f to i32
call void @web_fclose(i32 %h)
ret i32 0
}
define i32 @fseek(ptr %f, i32 %off, i32 %whence) {
%h = ptrtoint ptr %f to i32
%r = call i32 @web_fseek(i32 %h, i32 %off, i32 %whence)
ret i32 %r
}
define i32 @ftell(ptr %f) {
%h = ptrtoint ptr %f to i32
%r = call i32 @web_ftell(i32 %h)
ret i32 %r
}
define i64 @time(ptr %slot) {
%s = call i32 @web_time()
%r = sext i32 %s to i64
ret i64 %r
}
define void @exit(i32 %code) noreturn {
call void @web_exit(i32 %code)
unreachable
}
; stdin/stdout. A windowed web build never reads stdin — rt_poll() goes through
; win_poll() — but the intrinsic still has to resolve, and the headless path
; (used by the test suite under Node) genuinely uses both.
declare i32 @web_getchar()
declare void @web_putchar(i32)
define i32 @getchar() {
%c = call i32 @web_getchar()
ret i32 %c
}
define i32 @putchar(i32 %c) {
call void @web_putchar(i32 %c)
ret i32 %c
}
; `print_str` is the one intrinsic ludicc lowers differently for the web: on a
; native target it goes through variadic printf("%s"), which wasm's strict
; signature checking makes awkward, so the web backend emits a direct call to
; @web_print_str instead. It is a pure import — platform.js defines it — and so
; needs no adapter here.