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>
This commit is contained in:
commit
985f9ad8f2
418 changed files with 39065 additions and 0 deletions
32
runtime/web/imports.txt
Normal file
32
runtime/web/imports.txt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# runtime/web/imports.txt — every symbol a web build may leave undefined.
|
||||
#
|
||||
# ludicc passes this to wasm-ld as --allow-undefined-file, so these become the
|
||||
# module's wasm imports and *anything else* undefined is a link error at build
|
||||
# time rather than a LinkError in the browser. That matters because LLVM invents
|
||||
# libcalls (calloc, bcmp, memmove) that ludicc never emitted: without this list
|
||||
# they would silently become imports nobody implements.
|
||||
#
|
||||
# runtime/web/platform.js supplies all of them.
|
||||
|
||||
# the window — the same five-function protocol runtime/native/cocoa.ll implements
|
||||
win_open
|
||||
win_poll
|
||||
win_present
|
||||
win_running
|
||||
win_close
|
||||
|
||||
# files: a preloaded image of the game's assets, plus localStorage for saves
|
||||
web_fopen
|
||||
web_fread
|
||||
web_fwrite
|
||||
web_fclose
|
||||
web_fseek
|
||||
web_ftell
|
||||
|
||||
# stdin, stdout, the clock, and giving up
|
||||
web_getchar
|
||||
web_putchar
|
||||
web_print_str
|
||||
web_print_int
|
||||
web_time
|
||||
web_exit
|
||||
106
runtime/web/index.html
Normal file
106
runtime/web/index.html
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<!doctype html>
|
||||
<!-- =========================================================================
|
||||
runtime/web/index.html — the page a web build is served from.
|
||||
|
||||
ludicc copies this file next to the .wasm, substituting the module name and
|
||||
the game's title. It mounts a canvas, hands it to runtime/web/platform.js,
|
||||
and gets out of the way: everything you see after that is drawn by compiled
|
||||
Ludic writing pixels into its own framebuffer.
|
||||
========================================================================= -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: #101018;
|
||||
color: #c8c8d8;
|
||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
body {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 14px; padding: 16px;
|
||||
}
|
||||
h1 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #8a8aa8; }
|
||||
#screen {
|
||||
display: block;
|
||||
background: #000;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 3px;
|
||||
/* platform.js publishes --ludic-w/h/scale when the game opens its window.
|
||||
Draw at the scale it asked for, but never wider than the page or taller
|
||||
than the room left over — and derive the limit from the *width* so the
|
||||
aspect ratio survives, which a plain max-height would not do. */
|
||||
--fit-height: 72dvh;
|
||||
width: min(
|
||||
calc(var(--ludic-w, 320) * var(--ludic-scale, 1) * 1px),
|
||||
100%,
|
||||
calc(var(--fit-height) * var(--ludic-w, 320) / var(--ludic-h, 240))
|
||||
);
|
||||
image-rendering: pixelated;
|
||||
touch-action: none;
|
||||
}
|
||||
#status { min-height: 1.5em; color: #6a6a86; font-size: 12px; text-align: center; }
|
||||
#status.error { color: #d08a8a; }
|
||||
/* An on-screen d-pad, shown only where there is no keyboard to type on. */
|
||||
#pad { display: none; gap: 8px; }
|
||||
#pad button {
|
||||
width: 52px; height: 52px;
|
||||
font: inherit; font-size: 16px;
|
||||
color: #c8c8d8; background: #1c1c2a;
|
||||
border: 1px solid #33334a; border-radius: 6px;
|
||||
}
|
||||
#pad button:active { background: #2a2a3e; }
|
||||
@media (hover: none) and (pointer: coarse) { #pad { display: grid; grid-template-columns: repeat(3, auto); } }
|
||||
.spacer { visibility: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>{{TITLE}}</h1>
|
||||
<canvas id="screen" width="320" height="240"></canvas>
|
||||
<p id="status">loading…</p>
|
||||
|
||||
<div id="pad" aria-label="on-screen controls">
|
||||
<button class="spacer" tabindex="-1"></button>
|
||||
<button data-key="w" aria-label="up">↑</button>
|
||||
<button class="spacer" tabindex="-1"></button>
|
||||
<button data-key="a" aria-label="left">←</button>
|
||||
<button data-key=" " aria-label="confirm">•</button>
|
||||
<button data-key="d" aria-label="right">→</button>
|
||||
<button class="spacer" tabindex="-1"></button>
|
||||
<button data-key="s" aria-label="down">↓</button>
|
||||
<button class="spacer" tabindex="-1"></button>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { LudicWeb } from './platform.js';
|
||||
|
||||
const status = document.getElementById('status');
|
||||
|
||||
try {
|
||||
const game = await LudicWeb.start({
|
||||
wasm: '{{WASM}}',
|
||||
canvas: document.getElementById('screen'),
|
||||
manifest: 'assets.json',
|
||||
onExit: () => { status.textContent = 'game over — reload to play again'; },
|
||||
});
|
||||
status.textContent = 'running';
|
||||
|
||||
for (const b of document.querySelectorAll('#pad button[data-key]')) {
|
||||
const send = (ev) => { ev.preventDefault(); game.press(b.dataset.key); };
|
||||
b.addEventListener('pointerdown', send);
|
||||
}
|
||||
} catch (err) {
|
||||
status.className = 'error';
|
||||
status.textContent = 'failed to start: ' + err.message;
|
||||
throw err;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
357
runtime/web/platform.js
Normal file
357
runtime/web/platform.js
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
// ============================================================================
|
||||
// runtime/web/platform.js — the web's window, keyboard and filesystem.
|
||||
//
|
||||
// This file is the browser's answer to runtime/native/cocoa.ll, and it has the
|
||||
// same job and the same size: implement the five-function platform protocol
|
||||
//
|
||||
// win_open(w, h, scale, title) win_poll() -> key win_present(fb, w, h)
|
||||
// win_running() -> bool win_close()
|
||||
//
|
||||
// plus the handful of host services wasm has no OS to ask for — files, the
|
||||
// clock, stdout. Nothing else. The game, the ECS, the framebuffer, the PNG
|
||||
// decoder, the TrueType rasteriser and the UI are all compiled Ludic running
|
||||
// inside the wasm module; no game logic passes through this file, and none of
|
||||
// it is interpreted.
|
||||
//
|
||||
// const game = await LudicWeb.start({ wasm: 'chronorift.wasm',
|
||||
// canvas: document.querySelector('canvas'),
|
||||
// manifest: 'assets.json' })
|
||||
// ============================================================================
|
||||
|
||||
export const LudicWeb = { start };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard. win_poll() reports the last key pressed since the previous frame,
|
||||
// as a character code, or 0 for none — exactly what cocoa.ll reports. Arrow
|
||||
// keys fold onto WASD and Escape onto 'q', so a game written for the native
|
||||
// build needs no web-specific input handling.
|
||||
// ---------------------------------------------------------------------------
|
||||
const KEY_ALIASES = {
|
||||
ArrowUp: 'w', ArrowLeft: 'a', ArrowDown: 's', ArrowRight: 'd',
|
||||
Escape: 'q', Enter: '\r', Backspace: '\b', Tab: '\t',
|
||||
};
|
||||
|
||||
function keyCode(ev) {
|
||||
const alias = KEY_ALIASES[ev.key];
|
||||
if (alias) return alias.charCodeAt(0);
|
||||
if (ev.key.length === 1) return ev.key.charCodeAt(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A read-only image of the game's files, plus a writable overlay in
|
||||
// localStorage for save games.
|
||||
//
|
||||
// The Ludic runtime opens assets with file_open()/file_read(), which on a
|
||||
// native build is fopen/fread. The browser has no synchronous file access, so
|
||||
// every file the game can open is fetched up front (see the manifest ludicc
|
||||
// emits beside the .wasm) and served out of memory. Saves go the other way:
|
||||
// file_open("game.sav","wb") buffers, and fclose commits to localStorage, so
|
||||
// save/load survives a page reload.
|
||||
// ---------------------------------------------------------------------------
|
||||
export class Files {
|
||||
constructor(preloaded, storageKey) {
|
||||
this.assets = preloaded; // Map<string, Uint8Array>
|
||||
this.storageKey = storageKey;
|
||||
this.open = new Map(); // handle -> {data, pos, path, write}
|
||||
this.next = 1; // 0 is NULL to the Ludic side
|
||||
}
|
||||
|
||||
saved(path) {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.storageKey + path);
|
||||
if (raw === null) return null;
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i) & 0xff;
|
||||
return bytes;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
commit(path, data) {
|
||||
try {
|
||||
let raw = '';
|
||||
for (let i = 0; i < data.length; i += 0x8000)
|
||||
raw += String.fromCharCode.apply(null, data.subarray(i, i + 0x8000));
|
||||
localStorage.setItem(this.storageKey + path, raw);
|
||||
} catch (e) {
|
||||
console.warn('ludic: could not persist', path, e);
|
||||
}
|
||||
}
|
||||
|
||||
fopen(path, mode) {
|
||||
const writing = mode.includes('w') || mode.includes('a');
|
||||
if (writing) {
|
||||
const h = this.next++;
|
||||
this.open.set(h, { path, pos: 0, write: true, chunks: [], size: 0 });
|
||||
return h;
|
||||
}
|
||||
// Reads prefer a save the player has made over the shipped asset.
|
||||
const data = this.saved(path) || this.assets.get(path) ||
|
||||
this.assets.get(path.replace(/^\.\//, ''));
|
||||
if (!data) return 0; // NULL: the game handles it
|
||||
const h = this.next++;
|
||||
this.open.set(h, { path, pos: 0, write: false, data });
|
||||
return h;
|
||||
}
|
||||
|
||||
fread(h, dst, want) {
|
||||
const f = this.open.get(h);
|
||||
if (!f || f.write) return 0;
|
||||
const n = Math.max(0, Math.min(want, f.data.length - f.pos));
|
||||
dst.set(f.data.subarray(f.pos, f.pos + n));
|
||||
f.pos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
fwrite(h, src, n) {
|
||||
const f = this.open.get(h);
|
||||
if (!f || !f.write) return 0;
|
||||
f.chunks.push(src.slice(0, n)); // copy: wasm memory is reused
|
||||
f.size += n;
|
||||
f.pos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
fclose(h) {
|
||||
const f = this.open.get(h);
|
||||
if (!f) return;
|
||||
if (f.write) {
|
||||
const all = new Uint8Array(f.size);
|
||||
let at = 0;
|
||||
for (const c of f.chunks) { all.set(c, at); at += c.length; }
|
||||
this.commit(f.path, all);
|
||||
}
|
||||
this.open.delete(h);
|
||||
}
|
||||
|
||||
fseek(h, off, whence) {
|
||||
const f = this.open.get(h);
|
||||
if (!f) return -1;
|
||||
const end = f.write ? f.size : f.data.length;
|
||||
const base = whence === 1 ? f.pos : whence === 2 ? end : 0;
|
||||
f.pos = Math.max(0, Math.min(end, base + off));
|
||||
return 0;
|
||||
}
|
||||
|
||||
ftell(h) {
|
||||
const f = this.open.get(h);
|
||||
return f ? f.pos : -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The canvas. win_present() hands over a pointer to the framebuffer — one
|
||||
// 0x00RRGGBB word per pixel — which is transcoded into the RGBA byte order
|
||||
// ImageData wants and blitted at the requested integer scale.
|
||||
// ---------------------------------------------------------------------------
|
||||
class Screen {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = null;
|
||||
this.image = null;
|
||||
this.w = 0; this.h = 0;
|
||||
}
|
||||
|
||||
open(w, h, scale, title) {
|
||||
this.w = w; this.h = h;
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
// The backing store stays at the framebuffer's own resolution and CSS does
|
||||
// the scaling, so a 320x240 game stays crisp on a HiDPI display.
|
||||
//
|
||||
// How big to draw it is the page's decision, not the platform layer's: a 3x
|
||||
// 320x240 window is 720px tall and does not fit a laptop viewport once there
|
||||
// is a title above it. So this publishes the framebuffer's dimensions and
|
||||
// the scale the game asked for, and leaves the fitting to CSS — see the
|
||||
// width rule in runtime/web/index.html.
|
||||
const css = this.canvas.style;
|
||||
css.setProperty('--ludic-w', String(w));
|
||||
css.setProperty('--ludic-h', String(h));
|
||||
css.setProperty('--ludic-scale', String(scale));
|
||||
css.aspectRatio = w + ' / ' + h;
|
||||
css.height = 'auto';
|
||||
css.imageRendering = 'pixelated';
|
||||
this.ctx = this.canvas.getContext('2d', { alpha: false });
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.image = this.ctx.createImageData(w, h);
|
||||
this.rgba = new Uint32Array(this.image.data.buffer);
|
||||
if (title) document.title = title;
|
||||
}
|
||||
|
||||
present(words, w, h) {
|
||||
if (!this.ctx) return;
|
||||
if (w !== this.w || h !== this.h) this.open(w, h, 1, null);
|
||||
const dst = this.rgba;
|
||||
// Little-endian ImageData is 0xAABBGGRR; the framebuffer is 0x00RRGGBB.
|
||||
for (let i = 0, n = w * h; i < n; i++) {
|
||||
const c = words[i];
|
||||
dst[i] = 0xff000000 | ((c & 0xff) << 16) | (c & 0xff00) | ((c >>> 16) & 0xff);
|
||||
}
|
||||
this.ctx.putImageData(this.image, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host — the memory views and the services wasm has no OS to ask for.
|
||||
//
|
||||
// A wasm module's linear memory is detached and replaced every time it grows,
|
||||
// so every view has to be re-derived rather than cached across a call. Beyond
|
||||
// that this is just the non-window half of the platform: files, stdin, stdout
|
||||
// and the clock. The browser and Node need exactly the same set, so the Node
|
||||
// test runner (tools/ludic-web/run.mjs) builds on this class too, and only the
|
||||
// five win_* functions differ between the two hosts.
|
||||
// ---------------------------------------------------------------------------
|
||||
export class Host {
|
||||
constructor({ files, stdin = '', log = console.log } = {}) {
|
||||
this.files = files;
|
||||
this.stdin = stdin;
|
||||
this.stdinAt = 0;
|
||||
this.log = log;
|
||||
this.line = '';
|
||||
this.memory = null;
|
||||
this.buffer = null;
|
||||
this.running = 1;
|
||||
this.exitCode = null;
|
||||
}
|
||||
|
||||
bind(memory) { this.memory = memory; this.sync(); }
|
||||
|
||||
sync() {
|
||||
if (this.memory.buffer !== this.buffer) {
|
||||
this.buffer = this.memory.buffer;
|
||||
this.u8 = new Uint8Array(this.buffer);
|
||||
this.u32 = new Uint32Array(this.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
cstr(p) {
|
||||
this.sync();
|
||||
let end = p;
|
||||
while (this.u8[end] !== 0) end++;
|
||||
return new TextDecoder().decode(this.u8.subarray(p, end));
|
||||
}
|
||||
|
||||
flush() { if (this.line) { this.log(this.line); this.line = ''; } }
|
||||
|
||||
imports() {
|
||||
const fs = this.files;
|
||||
return {
|
||||
web_fopen: (path, mode) => fs.fopen(this.cstr(path), this.cstr(mode)),
|
||||
web_fread: (h, dst, n) => { this.sync(); return fs.fread(h, this.u8.subarray(dst, dst + n), n); },
|
||||
web_fwrite: (h, src, n) => { this.sync(); return fs.fwrite(h, this.u8.subarray(src, src + n), n); },
|
||||
web_fclose: (h) => fs.fclose(h),
|
||||
web_fseek: (h, off, whence) => fs.fseek(h, off, whence),
|
||||
web_ftell: (h) => fs.ftell(h),
|
||||
|
||||
web_getchar: () => (this.stdinAt < this.stdin.length ? this.stdin.charCodeAt(this.stdinAt++) : -1),
|
||||
web_putchar: (c) => { if (c === 10) this.flush(); else this.line += String.fromCharCode(c); },
|
||||
web_print_str: (p) => { this.line += this.cstr(p); },
|
||||
// print_int(n) is printf("%d\n", n) natively, newline included.
|
||||
web_print_int: (n) => { this.line += String(n); this.flush(); },
|
||||
web_time: () => Math.floor(Date.now() / 1000),
|
||||
web_exit: (code) => {
|
||||
this.running = 0; this.exitCode = code; this.flush();
|
||||
throw new ExitSignal(code);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// start() — fetch the module and its assets, wire the imports, run the loop.
|
||||
//
|
||||
// The native entry point is `while (alive) frame();`. A browser tab cannot be
|
||||
// held that way, so a web build exports the loop's parts instead —
|
||||
// ludic_boot / ludic_frame / ludic_alive / ludic_teardown — and this drives
|
||||
// them from requestAnimationFrame. The systems that run per frame, and their
|
||||
// order, are identical; only who owns the loop changes.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function start({ wasm, canvas, manifest = null, files = {}, onExit = null,
|
||||
stdin = '', storageKey = 'ludic:' } = {}) {
|
||||
const assets = new Map();
|
||||
for (const [path, bytes] of Object.entries(files)) assets.set(path, bytes);
|
||||
|
||||
if (manifest) {
|
||||
const list = await (await fetch(manifest)).json();
|
||||
await Promise.all(list.map(async (path) => {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) { console.warn('ludic: missing asset', path); return; }
|
||||
assets.set(path, new Uint8Array(await res.arrayBuffer()));
|
||||
}));
|
||||
}
|
||||
|
||||
const host = new Host({ files: new Files(assets, storageKey) });
|
||||
const screen = new Screen(canvas);
|
||||
let pendingKey = 0;
|
||||
|
||||
const env = {
|
||||
...host.imports(),
|
||||
// The window, which is the only part of the platform the browser and the
|
||||
// test runner do not share.
|
||||
win_open: (w, h, scale, title) => screen.open(w, h, scale || 1, host.cstr(title)),
|
||||
win_poll: () => { const k = pendingKey; pendingKey = 0; return k; },
|
||||
win_present: (fb, w, h) => {
|
||||
host.sync();
|
||||
screen.present(host.u32.subarray(fb >>> 2, (fb >>> 2) + w * h), w, h);
|
||||
},
|
||||
win_running: () => host.running,
|
||||
win_close: () => { host.running = 0; },
|
||||
};
|
||||
|
||||
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasm), { env });
|
||||
host.bind(instance.exports.memory);
|
||||
|
||||
const { ludic_boot, ludic_frame, ludic_alive, ludic_teardown } = instance.exports;
|
||||
|
||||
const onKey = (ev) => {
|
||||
const k = keyCode(ev);
|
||||
if (k) { pendingKey = k; ev.preventDefault(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
const game = {
|
||||
instance,
|
||||
exports: instance.exports,
|
||||
// Lets a page wire on-screen buttons for touch devices: game.press('w').
|
||||
press: (k) => { pendingKey = typeof k === 'string' ? k.charCodeAt(0) : k; },
|
||||
stop: () => { host.running = 0; },
|
||||
};
|
||||
|
||||
let stopped = false;
|
||||
const finish = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
try { ludic_teardown(); } catch (e) { if (!(e instanceof ExitSignal)) throw e; }
|
||||
host.flush();
|
||||
if (onExit) onExit(host.exitCode ?? 0);
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (!host.running || !ludic_alive()) return finish();
|
||||
ludic_frame();
|
||||
} catch (e) {
|
||||
if (e instanceof ExitSignal) return finish();
|
||||
stopped = true;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
throw e;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
try { ludic_boot(); } catch (e) {
|
||||
if (!(e instanceof ExitSignal)) throw e;
|
||||
finish();
|
||||
return game;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
return game;
|
||||
}
|
||||
|
||||
// os_exit() has to unwind the wasm frame it was called from; there is no
|
||||
// process to terminate, so it throws and the loop treats it as the end.
|
||||
export class ExitSignal extends Error {
|
||||
constructor(code) { super('ludic: exit(' + code + ')'); this.code = code; }
|
||||
}
|
||||
369
runtime/web/wasm.ll
Normal file
369
runtime/web/wasm.ll
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
; ============================================================================
|
||||
; 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue